mirror of
https://github.com/atuinsh/atuin.git
synced 2025-12-14 20:35:55 +01:00
feat: Interactive Inspector (#2319)
### What does this PR do? Adds simple navigation to the inspector, to explore a session starting from a single command. This creates a new user flow, where a user can find a history entry in the interactive view (in, say, Global mode), and hit Ctrl+o to navigate back and forward through that command's session. IMAGINED USE-CASE: I remembered that I did a sequence of git steps but I can't remember the order and forgot to document it. I remember that `reflog` was involved and want to see the actual sequence, and only those commands. IMAGINED USE-CASE: I used a curl command to get my IP address for greenlisting before I connected to the bastion server `abc.xyz` over SSH - I could easily find the SSH command with abc.xyz, and go back one step in the session, but without this change, scrolling through all my curl commands ever run to find a forgotten URL/domain would be too much work. Since this gives the inspector tab a broader purpose than viewing analytics, it needs to function even when there are not enough screen rows for charts -- hence, this PR also introduces an ultracompact mode for the inspector that _just_ shows the neighbouring history commands (as simple scrolling three-entry list, with no panes) if there are fewer than `auto_hide_height` rows (default: 8). Otherwise, the inspector behaves as normal, except that Up / Down will change the focused command by navigating through the session. That means there is no "compact" mode for the inspector - when the interactive search is compact (but not ultracompact), the inspector shows its usual chart view. The UX for this could be improved - to keep this PR as lean as it realistically can be, I have tried to keep the flow very minimal, but a follow-up PR could introduce some tooltips, nicer ultracompact formatting, etc. A minor QoL improvement that comes with this - since I had to deal with bold text and would otherwise have need a theming exception, I took the opportunity to ensure the theme engine sets styles completely (so a theme can have bold), not just colours. To limit scope creep, I do not add TOML syntax so (for now) you can only customize colours from config files, but it means that default-bold text (etc.) can now use the theming engine if the code-defined default Meaning is bolded. Key changes: * introduces a simplified inspector tab, with only previous-current-next commands as rows, in compact mode * allows navigation through session history within the inspector (so compact inspector view is still useful) It also (see comments below): * makes `compact` into `compactness`, an enum (to better standardize across inspector/interactive) * makes the inspector _only_ change layout for ultracompact mode, which is still compact+(height<8) * clippy's complexity limit wanted draw split up a little, so not sure if this is a reasonable minimal way to do so for now * adds a `(none)` theme to the theming to enable output testing without styling * ~~additional tests, although keen for input on how best to do these~~ one functional test, as a starting point * ~~documentation~~ [minor doc changes only](https://github.com/atuinsh/docs/pull/72), as I am not sure there is much to say <!-- Thank you for making a PR! Bug fixes are always welcome, but if you're adding a new feature or changing an existing one, we'd really appreciate if you open an issue, post on the forum, or drop in on Discord --> _Was stacked on #2357, which is now in `main`_ ## Checks - [x] I am happy for maintainers to push small adjustments to this PR, to speed up the review cycle - [x] I have checked that there are no existing pull requests for the same thing
This commit is contained in:
@@ -51,7 +51,7 @@ pub struct ThemeDefinitionConfigBlock {
|
||||
pub parent: Option<String>,
|
||||
}
|
||||
|
||||
use crossterm::style::{Color, ContentStyle};
|
||||
use crossterm::style::{Attribute, Attributes, Color, ContentStyle};
|
||||
|
||||
// For now, a theme is loaded as a mapping of meanings to colors, but it may be desirable to
|
||||
// expand that in the future to general styles, so we populate a Meaning->ContentStyle hashmap.
|
||||
@@ -228,6 +228,14 @@ impl StyleFactory {
|
||||
..ContentStyle::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn from_fg_color_and_attributes(color: Color, attributes: Attributes) -> ContentStyle {
|
||||
ContentStyle {
|
||||
foreground_color: Some(color),
|
||||
attributes,
|
||||
..ContentStyle::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Built-in themes. Rather than having extra files added before any theming
|
||||
@@ -275,7 +283,10 @@ lazy_static! {
|
||||
),
|
||||
(
|
||||
Meaning::Important,
|
||||
StyleFactory::from_fg_color(Color::White),
|
||||
StyleFactory::from_fg_color_and_attributes(
|
||||
Color::White,
|
||||
Attributes::from(Attribute::Bold),
|
||||
),
|
||||
),
|
||||
(Meaning::Muted, StyleFactory::from_fg_color(Color::Grey)),
|
||||
(Meaning::Base, ContentStyle::default()),
|
||||
@@ -285,6 +296,19 @@ lazy_static! {
|
||||
static ref BUILTIN_THEMES: HashMap<&'static str, Theme> = {
|
||||
HashMap::from([
|
||||
("default", HashMap::new()),
|
||||
(
|
||||
"(none)",
|
||||
HashMap::from([
|
||||
(Meaning::AlertError, ContentStyle::default()),
|
||||
(Meaning::AlertWarn, ContentStyle::default()),
|
||||
(Meaning::AlertInfo, ContentStyle::default()),
|
||||
(Meaning::Annotation, ContentStyle::default()),
|
||||
(Meaning::Guidance, ContentStyle::default()),
|
||||
(Meaning::Important, ContentStyle::default()),
|
||||
(Meaning::Muted, ContentStyle::default()),
|
||||
(Meaning::Base, ContentStyle::default()),
|
||||
]),
|
||||
),
|
||||
(
|
||||
"autumn",
|
||||
HashMap::from([
|
||||
@@ -430,7 +454,7 @@ impl ThemeManager {
|
||||
}
|
||||
Some(self.load_theme(parent_name.as_str(), Some(max_depth - 1)))
|
||||
}
|
||||
None => None,
|
||||
None => Some(self.load_theme("default", Some(max_depth - 1))),
|
||||
};
|
||||
|
||||
if debug && name != theme_config.theme.name {
|
||||
@@ -461,7 +485,7 @@ impl ThemeManager {
|
||||
Ok(theme) => theme,
|
||||
Err(err) => {
|
||||
log::warn!("Could not load theme {name}: {err}");
|
||||
built_ins.get("default").unwrap()
|
||||
built_ins.get("(none)").unwrap()
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -669,7 +693,8 @@ mod theme_tests {
|
||||
|
||||
testing_logger::validate(|captured_logs| assert_eq!(captured_logs.len(), 0));
|
||||
|
||||
// If the parent is not found, we end up with the base theme colors
|
||||
// If the parent is not found, we end up with the no theme colors or styling
|
||||
// as this is considered a (soft) error state.
|
||||
let nunsolarized = Config::builder()
|
||||
.add_source(ConfigFile::from_str(
|
||||
"
|
||||
@@ -692,7 +717,7 @@ mod theme_tests {
|
||||
nunsolarized_theme
|
||||
.as_style(Meaning::Guidance)
|
||||
.foreground_color,
|
||||
Some(Color::DarkBlue)
|
||||
None
|
||||
);
|
||||
|
||||
testing_logger::validate(|captured_logs| {
|
||||
|
||||
@@ -11,13 +11,14 @@ use ratatui::{
|
||||
layout::Rect,
|
||||
prelude::{Constraint, Direction, Layout},
|
||||
style::Style,
|
||||
text::{Span, Text},
|
||||
widgets::{Bar, BarChart, BarGroup, Block, Borders, Padding, Paragraph, Row, Table},
|
||||
};
|
||||
|
||||
use super::duration::format_duration;
|
||||
|
||||
use super::super::theme::{Meaning, Theme};
|
||||
use super::interactive::{InputAction, State};
|
||||
use super::interactive::{Compactness, InputAction, State, to_compactness};
|
||||
|
||||
#[allow(clippy::cast_sign_loss)]
|
||||
fn u64_or_zero(num: i64) -> u64 {
|
||||
@@ -29,52 +30,83 @@ pub fn draw_commands(
|
||||
parent: Rect,
|
||||
history: &History,
|
||||
stats: &HistoryStats,
|
||||
compact: bool,
|
||||
theme: &Theme,
|
||||
) {
|
||||
let commands = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Ratio(1, 4),
|
||||
Constraint::Ratio(1, 2),
|
||||
Constraint::Ratio(1, 4),
|
||||
])
|
||||
.direction(if compact {
|
||||
Direction::Vertical
|
||||
} else {
|
||||
Direction::Horizontal
|
||||
})
|
||||
.constraints(if compact {
|
||||
[
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
]
|
||||
} else {
|
||||
[
|
||||
Constraint::Ratio(1, 4),
|
||||
Constraint::Ratio(1, 2),
|
||||
Constraint::Ratio(1, 4),
|
||||
]
|
||||
})
|
||||
.split(parent);
|
||||
|
||||
let command = Paragraph::new(history.command.clone()).block(
|
||||
let command = Paragraph::new(Text::from(Span::styled(
|
||||
history.command.clone(),
|
||||
theme.as_style(Meaning::Important),
|
||||
)))
|
||||
.block(if compact {
|
||||
Block::new()
|
||||
.borders(Borders::NONE)
|
||||
.style(theme.as_style(Meaning::Base))
|
||||
} else {
|
||||
Block::new()
|
||||
.borders(Borders::ALL)
|
||||
.title("Command")
|
||||
.style(theme.as_style(Meaning::Base))
|
||||
.padding(Padding::horizontal(1)),
|
||||
);
|
||||
.title("Command")
|
||||
.padding(Padding::horizontal(1))
|
||||
});
|
||||
|
||||
let previous = Paragraph::new(
|
||||
stats
|
||||
.previous
|
||||
.clone()
|
||||
.map_or_else(|| "No previous command".to_string(), |prev| prev.command),
|
||||
.map_or_else(|| "[No previous command]".to_string(), |prev| prev.command),
|
||||
)
|
||||
.block(
|
||||
.block(if compact {
|
||||
Block::new()
|
||||
.borders(Borders::NONE)
|
||||
.style(theme.as_style(Meaning::Annotation))
|
||||
} else {
|
||||
Block::new()
|
||||
.borders(Borders::ALL)
|
||||
.title("Previous command")
|
||||
.style(theme.as_style(Meaning::Annotation))
|
||||
.padding(Padding::horizontal(1)),
|
||||
);
|
||||
.title("Previous command")
|
||||
.padding(Padding::horizontal(1))
|
||||
});
|
||||
|
||||
// Add [] around blank text, as when this is shown in a list
|
||||
// compacted, it makes it more obviously control text.
|
||||
let next = Paragraph::new(
|
||||
stats
|
||||
.next
|
||||
.clone()
|
||||
.map_or_else(|| "No next command".to_string(), |next| next.command),
|
||||
.map_or_else(|| "[No next command]".to_string(), |next| next.command),
|
||||
)
|
||||
.block(
|
||||
.block(if compact {
|
||||
Block::new()
|
||||
.borders(Borders::NONE)
|
||||
.style(theme.as_style(Meaning::Annotation))
|
||||
} else {
|
||||
Block::new()
|
||||
.borders(Borders::ALL)
|
||||
.title("Next command")
|
||||
.padding(Padding::horizontal(1))
|
||||
.style(theme.as_style(Meaning::Annotation))
|
||||
.padding(Padding::horizontal(1)),
|
||||
);
|
||||
});
|
||||
|
||||
f.render_widget(previous, commands[0]);
|
||||
f.render_widget(command, commands[1]);
|
||||
@@ -254,6 +286,33 @@ fn draw_stats_charts(f: &mut Frame<'_>, parent: Rect, stats: &HistoryStats, them
|
||||
}
|
||||
|
||||
pub fn draw(
|
||||
f: &mut Frame<'_>,
|
||||
chunk: Rect,
|
||||
history: &History,
|
||||
stats: &HistoryStats,
|
||||
settings: &Settings,
|
||||
theme: &Theme,
|
||||
tz: Timezone,
|
||||
) {
|
||||
let compactness = to_compactness(f, settings);
|
||||
|
||||
match compactness {
|
||||
Compactness::Ultracompact => draw_ultracompact(f, chunk, history, stats, theme),
|
||||
_ => draw_full(f, chunk, history, stats, theme, tz),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_ultracompact(
|
||||
f: &mut Frame<'_>,
|
||||
chunk: Rect,
|
||||
history: &History,
|
||||
stats: &HistoryStats,
|
||||
theme: &Theme,
|
||||
) {
|
||||
draw_commands(f, chunk, history, stats, true, theme);
|
||||
}
|
||||
|
||||
pub fn draw_full(
|
||||
f: &mut Frame<'_>,
|
||||
chunk: Rect,
|
||||
history: &History,
|
||||
@@ -271,7 +330,7 @@ pub fn draw(
|
||||
.constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)])
|
||||
.split(vert_layout[1]);
|
||||
|
||||
draw_commands(f, vert_layout[0], history, stats, theme);
|
||||
draw_commands(f, vert_layout[0], history, stats, false, theme);
|
||||
draw_stats_table(f, stats_layout[0], history, tz, stats, theme);
|
||||
draw_stats_charts(f, stats_layout[1], stats, theme);
|
||||
}
|
||||
@@ -279,7 +338,7 @@ pub fn draw(
|
||||
// I'm going to break this out more, but just starting to move things around before changing
|
||||
// structure and making it nicer.
|
||||
pub fn input(
|
||||
_state: &mut State,
|
||||
state: &mut State,
|
||||
_settings: &Settings,
|
||||
selected: usize,
|
||||
input: &KeyEvent,
|
||||
@@ -288,6 +347,93 @@ pub fn input(
|
||||
|
||||
match input.code {
|
||||
KeyCode::Char('d') if ctrl => InputAction::Delete(selected),
|
||||
KeyCode::Up => {
|
||||
state.inspecting_state.move_to_previous();
|
||||
InputAction::Redraw
|
||||
}
|
||||
KeyCode::Down => {
|
||||
state.inspecting_state.move_to_next();
|
||||
InputAction::Redraw
|
||||
}
|
||||
_ => InputAction::Continue,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::draw_ultracompact;
|
||||
use atuin_client::{
|
||||
history::{History, HistoryId, HistoryStats},
|
||||
theme::ThemeManager,
|
||||
};
|
||||
use ratatui::{backend::TestBackend, prelude::*};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
fn mock_history_stats() -> (History, HistoryStats) {
|
||||
let history = History {
|
||||
id: HistoryId::from("test1".to_string()),
|
||||
timestamp: OffsetDateTime::now_utc(),
|
||||
duration: 3,
|
||||
exit: 0,
|
||||
command: "/bin/cmd".to_string(),
|
||||
cwd: "/toot".to_string(),
|
||||
session: "sesh1".to_string(),
|
||||
hostname: "hostn".to_string(),
|
||||
deleted_at: None,
|
||||
};
|
||||
let next = History {
|
||||
id: HistoryId::from("test2".to_string()),
|
||||
timestamp: OffsetDateTime::now_utc(),
|
||||
duration: 2,
|
||||
exit: 0,
|
||||
command: "/bin/cmd -os".to_string(),
|
||||
cwd: "/toot".to_string(),
|
||||
session: "sesh1".to_string(),
|
||||
hostname: "hostn".to_string(),
|
||||
deleted_at: None,
|
||||
};
|
||||
let prev = History {
|
||||
id: HistoryId::from("test3".to_string()),
|
||||
timestamp: OffsetDateTime::now_utc(),
|
||||
duration: 1,
|
||||
exit: 0,
|
||||
command: "/bin/cmd -a".to_string(),
|
||||
cwd: "/toot".to_string(),
|
||||
session: "sesh1".to_string(),
|
||||
hostname: "hostn".to_string(),
|
||||
deleted_at: None,
|
||||
};
|
||||
let stats = HistoryStats {
|
||||
next: Some(next.clone()),
|
||||
previous: Some(prev.clone()),
|
||||
total: 2,
|
||||
average_duration: 3,
|
||||
exits: Vec::new(),
|
||||
day_of_week: Vec::new(),
|
||||
duration_over_time: Vec::new(),
|
||||
};
|
||||
(history, stats)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_looks_correct_for_ultracompact() {
|
||||
let backend = TestBackend::new(22, 5);
|
||||
let mut terminal = Terminal::new(backend).expect("Could not create terminal");
|
||||
let chunk = Rect::new(0, 0, 22, 5);
|
||||
let (history, stats) = mock_history_stats();
|
||||
let prev = stats.previous.clone().unwrap();
|
||||
let next = stats.next.clone().unwrap();
|
||||
|
||||
let mut manager = ThemeManager::new(Some(true), Some("".to_string()));
|
||||
let theme = manager.load_theme("(none)", None);
|
||||
let _ = terminal.draw(|f| draw_ultracompact(f, chunk, &history, &stats, &theme));
|
||||
let mut lines = [" "; 5].map(|l| Line::from(l));
|
||||
for (n, entry) in [prev, history, next].iter().enumerate() {
|
||||
let mut l = lines[n].to_string();
|
||||
l.replace_range(0..entry.command.len(), &entry.command);
|
||||
lines[n] = Line::from(l);
|
||||
}
|
||||
|
||||
terminal.backend().assert_buffer_lines(lines);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ use super::{
|
||||
};
|
||||
use atuin_client::{
|
||||
database::{Database, current_context},
|
||||
history::{History, HistoryStats, store::HistoryStore},
|
||||
history::{History, HistoryId, HistoryStats, store::HistoryStore},
|
||||
settings::{
|
||||
CursorStyle, ExitMode, FilterMode, KeymapMode, PreviewStrategy, SearchMode, Settings,
|
||||
},
|
||||
@@ -54,6 +54,7 @@ const TAB_TITLES: [&str; 2] = ["Search", "Inspect"];
|
||||
|
||||
pub enum InputAction {
|
||||
Accept(usize),
|
||||
AcceptInspecting,
|
||||
Copy(usize),
|
||||
Delete(usize),
|
||||
ReturnOriginal,
|
||||
@@ -62,6 +63,49 @@ pub enum InputAction {
|
||||
Redraw,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct InspectingState {
|
||||
current: Option<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)]
|
||||
pub struct State {
|
||||
history_count: i64,
|
||||
@@ -76,14 +120,23 @@ pub struct State {
|
||||
current_cursor: Option<CursorStyle>,
|
||||
tab_index: usize,
|
||||
|
||||
pub inspecting_state: InspectingState,
|
||||
|
||||
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 {
|
||||
compact: bool,
|
||||
compactness: Compactness,
|
||||
invert: bool,
|
||||
inner_width: usize,
|
||||
}
|
||||
@@ -96,6 +149,11 @@ impl State {
|
||||
) -> 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();
|
||||
|
||||
@@ -227,7 +285,13 @@ impl State {
|
||||
KeyCode::Char('c' | 'g') if ctrl => Some(InputAction::ReturnOriginal),
|
||||
KeyCode::Esc if esc_allow_exit => Some(Self::handle_key_exit(settings)),
|
||||
KeyCode::Char('[') if ctrl && esc_allow_exit => Some(Self::handle_key_exit(settings)),
|
||||
KeyCode::Tab => Some(InputAction::Accept(self.results_state.selected())),
|
||||
KeyCode::Tab => match self.tab_index {
|
||||
0 => Some(InputAction::Accept(self.results_state.selected())),
|
||||
|
||||
1 => Some(InputAction::AcceptInspecting),
|
||||
|
||||
_ => panic!("invalid tab index on input"),
|
||||
},
|
||||
KeyCode::Right if cursor_at_end_of_line && settings.keys.accept_past_line_end => {
|
||||
Some(InputAction::Accept(self.results_state.selected()))
|
||||
}
|
||||
@@ -532,6 +596,7 @@ impl State {
|
||||
|
||||
fn scroll_down(&mut self, scroll_len: usize) {
|
||||
let i = self.results_state.selected().saturating_sub(scroll_len);
|
||||
self.inspecting_state.reset();
|
||||
self.results_state.select(i);
|
||||
}
|
||||
|
||||
@@ -539,6 +604,7 @@ impl State {
|
||||
let i = self.results_state.selected() + scroll_len;
|
||||
self.results_state
|
||||
.select(i.min(self.results_len.saturating_sub(1)));
|
||||
self.inspecting_state.reset();
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
@@ -548,7 +614,7 @@ impl State {
|
||||
results: &[History],
|
||||
selected: usize,
|
||||
tab_index: usize,
|
||||
compact: bool,
|
||||
compactness: Compactness,
|
||||
border_size: u16,
|
||||
preview_width: u16,
|
||||
) -> u16 {
|
||||
@@ -608,14 +674,13 @@ impl State {
|
||||
}) + border_size * 2
|
||||
} else if settings.show_preview && settings.preview.strategy == PreviewStrategy::Fixed {
|
||||
settings.max_preview_height + border_size * 2
|
||||
} else if compact || tab_index == 1 {
|
||||
} else if !matches!(compactness, Compactness::Full) || tab_index == 1 {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
#[allow(clippy::bool_to_int_with_if)]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn draw(
|
||||
@@ -623,33 +688,31 @@ impl State {
|
||||
f: &mut Frame,
|
||||
results: &[History],
|
||||
stats: Option<HistoryStats>,
|
||||
inspecting: Option<&History>,
|
||||
settings: &Settings,
|
||||
theme: &Theme,
|
||||
) {
|
||||
let compact = match settings.style {
|
||||
atuin_client::settings::Style::Auto => f.area().height < 14,
|
||||
atuin_client::settings::Style::Compact => true,
|
||||
atuin_client::settings::Style::Full => false,
|
||||
};
|
||||
let compactness = to_compactness(f, settings);
|
||||
let invert = settings.invert;
|
||||
let border_size = if compact { 0 } else { 1 };
|
||||
let border_size = match compactness {
|
||||
Compactness::Full => 1,
|
||||
_ => 0,
|
||||
};
|
||||
let preview_width = f.area().width - 2;
|
||||
let preview_height = Self::calc_preview_height(
|
||||
settings,
|
||||
results,
|
||||
self.results_state.selected(),
|
||||
self.tab_index,
|
||||
compact,
|
||||
compactness,
|
||||
border_size,
|
||||
preview_width,
|
||||
);
|
||||
let show_help = settings.show_help && (!compact || f.area().height > 1);
|
||||
let show_help =
|
||||
settings.show_help && (matches!(compactness, Compactness::Full) || f.area().height > 1);
|
||||
// This is an OR, as it seems more likely for someone to wish to override
|
||||
// tabs unexpectedly being missed, than unexpectedly present.
|
||||
let hide_extra = settings.auto_hide_height != 0
|
||||
&& compact
|
||||
&& f.area().height <= settings.auto_hide_height;
|
||||
let show_tabs = settings.show_tabs && !hide_extra;
|
||||
let show_tabs = settings.show_tabs && !matches!(compactness, Compactness::Ultracompact);
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.margin(0)
|
||||
@@ -663,22 +726,23 @@ impl State {
|
||||
Constraint::Length(if show_tabs { 1 } else { 0 }), // tabs
|
||||
Constraint::Length(if show_help { 1 } else { 0 }), // header (sic)
|
||||
]
|
||||
} else if hide_extra {
|
||||
[
|
||||
Constraint::Length(if show_help { 1 } else { 0 }), // header
|
||||
Constraint::Length(0), // tabs
|
||||
Constraint::Min(1), // results list
|
||||
Constraint::Length(0),
|
||||
Constraint::Length(0),
|
||||
]
|
||||
} else {
|
||||
[
|
||||
Constraint::Length(if show_help { 1 } else { 0 }), // header
|
||||
Constraint::Length(if show_tabs { 1 } else { 0 }), // tabs
|
||||
Constraint::Min(1), // results list
|
||||
Constraint::Length(1 + border_size), // input
|
||||
Constraint::Length(preview_height), // preview
|
||||
]
|
||||
match compactness {
|
||||
Compactness::Ultracompact => [
|
||||
Constraint::Length(if show_help { 1 } else { 0 }), // header
|
||||
Constraint::Length(0), // tabs
|
||||
Constraint::Min(1), // results list
|
||||
Constraint::Length(0),
|
||||
Constraint::Length(0),
|
||||
],
|
||||
_ => [
|
||||
Constraint::Length(if show_help { 1 } else { 0 }), // header
|
||||
Constraint::Length(if show_tabs { 1 } else { 0 }), // tabs
|
||||
Constraint::Min(1), // results list
|
||||
Constraint::Length(1 + border_size), // input
|
||||
Constraint::Length(preview_height), // preview
|
||||
],
|
||||
}
|
||||
}
|
||||
.as_ref(),
|
||||
)
|
||||
@@ -700,13 +764,13 @@ impl State {
|
||||
.block(Block::default().borders(Borders::NONE))
|
||||
.select(self.tab_index)
|
||||
.style(Style::default())
|
||||
.highlight_style(Style::default().bold().white().on_black());
|
||||
.highlight_style(theme.as_style(Meaning::Important));
|
||||
|
||||
f.render_widget(tabs, tabs_chunk);
|
||||
}
|
||||
|
||||
let style = StyleState {
|
||||
compact,
|
||||
compactness,
|
||||
invert,
|
||||
inner_width: input_chunk.width.into(),
|
||||
};
|
||||
@@ -732,15 +796,18 @@ impl State {
|
||||
let stats_tab = self.build_stats(theme);
|
||||
f.render_widget(stats_tab, header_chunks[2]);
|
||||
|
||||
let indicator: String = if !hide_extra {
|
||||
" > ".to_string()
|
||||
} else if self.switched_search_mode {
|
||||
format!("S{}>", self.search_mode.as_str().chars().next().unwrap())
|
||||
} else {
|
||||
format!(
|
||||
"{}> ",
|
||||
self.search.filter_mode.as_str().chars().next().unwrap()
|
||||
)
|
||||
let indicator: String = match compactness {
|
||||
Compactness::Ultracompact => {
|
||||
if self.switched_search_mode {
|
||||
format!("S{}>", self.search_mode.as_str().chars().next().unwrap())
|
||||
} else {
|
||||
format!(
|
||||
"{}> ",
|
||||
self.search.filter_mode.as_str().chars().next().unwrap()
|
||||
)
|
||||
}
|
||||
}
|
||||
_ => " > ".to_string(),
|
||||
};
|
||||
|
||||
match self.tab_index {
|
||||
@@ -775,11 +842,16 @@ impl State {
|
||||
.alignment(Alignment::Center);
|
||||
f.render_widget(message, results_list_chunk);
|
||||
} else {
|
||||
let inspecting = match inspecting {
|
||||
Some(inspecting) => inspecting,
|
||||
None => &results[self.results_state.selected()],
|
||||
};
|
||||
super::inspector::draw(
|
||||
f,
|
||||
results_list_chunk,
|
||||
&results[self.results_state.selected()],
|
||||
inspecting,
|
||||
&stats.expect("Drawing inspector, but no stats"),
|
||||
settings,
|
||||
theme,
|
||||
settings.timezone,
|
||||
);
|
||||
@@ -800,35 +872,50 @@ impl State {
|
||||
}
|
||||
}
|
||||
|
||||
if !hide_extra {
|
||||
let input = self.build_input(style);
|
||||
f.render_widget(input, input_chunk);
|
||||
|
||||
let preview_width = if compact {
|
||||
preview_width
|
||||
} else {
|
||||
preview_width - 2
|
||||
if !matches!(compactness, Compactness::Ultracompact) {
|
||||
let preview_width = match compactness {
|
||||
Compactness::Full => preview_width - 2,
|
||||
_ => preview_width,
|
||||
};
|
||||
let preview = self.build_preview(
|
||||
results,
|
||||
compact,
|
||||
compactness,
|
||||
preview_width,
|
||||
preview_chunk.width.into(),
|
||||
theme,
|
||||
);
|
||||
f.render_widget(preview, preview_chunk);
|
||||
|
||||
let extra_width = UnicodeWidthStr::width(self.search.input.substring());
|
||||
|
||||
let cursor_offset = if compact { 0 } else { 1 };
|
||||
f.set_cursor_position((
|
||||
// Put cursor past the end of the input text
|
||||
input_chunk.x + extra_width as u16 + PREFIX_LENGTH + 1 + cursor_offset,
|
||||
input_chunk.y + cursor_offset,
|
||||
));
|
||||
self.draw_preview(f, style, input_chunk, compactness, preview_chunk, preview);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
fn draw_preview(
|
||||
&self,
|
||||
f: &mut Frame,
|
||||
style: StyleState,
|
||||
input_chunk: Rect,
|
||||
compactness: Compactness,
|
||||
preview_chunk: Rect,
|
||||
preview: Paragraph,
|
||||
) {
|
||||
let input = self.build_input(style);
|
||||
f.render_widget(input, input_chunk);
|
||||
|
||||
f.render_widget(preview, preview_chunk);
|
||||
|
||||
let extra_width = UnicodeWidthStr::width(self.search.input.substring());
|
||||
|
||||
let cursor_offset = match compactness {
|
||||
Compactness::Full => 1,
|
||||
_ => 0,
|
||||
};
|
||||
f.set_cursor_position((
|
||||
// Put cursor past the end of the input text
|
||||
input_chunk.x + extra_width as u16 + PREFIX_LENGTH + 1 + cursor_offset,
|
||||
input_chunk.y + cursor_offset,
|
||||
));
|
||||
}
|
||||
|
||||
fn build_title(&self, theme: &Theme) -> Paragraph<'_> {
|
||||
let title = if self.update_needed.is_some() {
|
||||
let error_style: Style = theme.get_error().into();
|
||||
@@ -916,21 +1003,24 @@ impl State {
|
||||
show_numeric_shortcuts,
|
||||
);
|
||||
|
||||
if style.compact {
|
||||
results_list
|
||||
} else if style.invert {
|
||||
results_list.block(
|
||||
Block::default()
|
||||
.borders(Borders::LEFT | Borders::RIGHT)
|
||||
.border_type(BorderType::Rounded)
|
||||
.title(format!("{:─>width$}", "", width = style.inner_width - 2)),
|
||||
)
|
||||
} else {
|
||||
results_list.block(
|
||||
Block::default()
|
||||
.borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
|
||||
.border_type(BorderType::Rounded),
|
||||
)
|
||||
match style.compactness {
|
||||
Compactness::Full => {
|
||||
if style.invert {
|
||||
results_list.block(
|
||||
Block::default()
|
||||
.borders(Borders::LEFT | Borders::RIGHT)
|
||||
.border_type(BorderType::Rounded)
|
||||
.title(format!("{:─>width$}", "", width = style.inner_width - 2)),
|
||||
)
|
||||
} else {
|
||||
results_list.block(
|
||||
Block::default()
|
||||
.borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
|
||||
.border_type(BorderType::Rounded),
|
||||
)
|
||||
}
|
||||
}
|
||||
_ => results_list,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -947,28 +1037,31 @@ impl State {
|
||||
debug_assert!(mode_width >= mode.len(), "mode name '{mode}' is too long!");
|
||||
let input = format!("[{pref}{mode:^mode_width$}] {}", self.search.input.as_str(),);
|
||||
let input = Paragraph::new(input);
|
||||
if style.compact {
|
||||
input
|
||||
} else if style.invert {
|
||||
input.block(
|
||||
Block::default()
|
||||
.borders(Borders::LEFT | Borders::RIGHT | Borders::TOP)
|
||||
.border_type(BorderType::Rounded),
|
||||
)
|
||||
} else {
|
||||
input.block(
|
||||
Block::default()
|
||||
.borders(Borders::LEFT | Borders::RIGHT)
|
||||
.border_type(BorderType::Rounded)
|
||||
.title(format!("{:─>width$}", "", width = style.inner_width - 2)),
|
||||
)
|
||||
match style.compactness {
|
||||
Compactness::Full => {
|
||||
if style.invert {
|
||||
input.block(
|
||||
Block::default()
|
||||
.borders(Borders::LEFT | Borders::RIGHT | Borders::TOP)
|
||||
.border_type(BorderType::Rounded),
|
||||
)
|
||||
} else {
|
||||
input.block(
|
||||
Block::default()
|
||||
.borders(Borders::LEFT | Borders::RIGHT)
|
||||
.border_type(BorderType::Rounded)
|
||||
.title(format!("{:─>width$}", "", width = style.inner_width - 2)),
|
||||
)
|
||||
}
|
||||
}
|
||||
_ => input,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_preview(
|
||||
&self,
|
||||
results: &[History],
|
||||
compact: bool,
|
||||
compactness: Compactness,
|
||||
preview_width: u16,
|
||||
chunk_width: usize,
|
||||
theme: &Theme,
|
||||
@@ -991,15 +1084,14 @@ impl State {
|
||||
.join("\n")
|
||||
};
|
||||
|
||||
if compact {
|
||||
Paragraph::new(command).style(theme.as_style(Meaning::Annotation))
|
||||
} else {
|
||||
Paragraph::new(command).block(
|
||||
match compactness {
|
||||
Compactness::Full => Paragraph::new(command).block(
|
||||
Block::default()
|
||||
.borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
|
||||
.border_type(BorderType::Rounded)
|
||||
.title(format!("{:─>width$}", "", width = chunk_width - 2)),
|
||||
)
|
||||
),
|
||||
_ => Paragraph::new(command).style(theme.as_style(Meaning::Annotation)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1158,6 +1250,11 @@ pub async fn history(
|
||||
switched_search_mode: false,
|
||||
search_mode,
|
||||
tab_index: 0,
|
||||
inspecting_state: InspectingState {
|
||||
current: None,
|
||||
next: None,
|
||||
previous: None,
|
||||
},
|
||||
search: SearchState {
|
||||
input,
|
||||
filter_mode: settings
|
||||
@@ -1194,9 +1291,19 @@ pub async fn history(
|
||||
}
|
||||
|
||||
let mut stats: Option<HistoryStats> = None;
|
||||
let mut inspecting: Option<History> = None;
|
||||
let accept;
|
||||
let result = 'render: loop {
|
||||
terminal.draw(|f| app.draw(f, &results, stats.clone(), settings, theme))?;
|
||||
terminal.draw(|f| {
|
||||
app.draw(
|
||||
f,
|
||||
&results,
|
||||
stats.clone(),
|
||||
inspecting.as_ref(),
|
||||
settings,
|
||||
theme,
|
||||
);
|
||||
})?;
|
||||
|
||||
let initial_input = app.search.input.as_str().to_owned();
|
||||
let initial_filter_mode = app.search.filter_mode;
|
||||
@@ -1217,6 +1324,7 @@ pub async fn history(
|
||||
app.results_len -= 1;
|
||||
let selected = app.results_state.selected();
|
||||
if selected == app.results_len {
|
||||
app.inspecting_state.reset();
|
||||
app.results_state.select(selected - 1);
|
||||
}
|
||||
|
||||
@@ -1233,7 +1341,7 @@ pub async fn history(
|
||||
},
|
||||
InputAction::Redraw => {
|
||||
terminal.clear()?;
|
||||
terminal.draw(|f| app.draw(f, &results, stats.clone(), settings, theme))?;
|
||||
terminal.draw(|f| app.draw(f, &results, stats.clone(), inspecting.as_ref(), settings, theme))?;
|
||||
},
|
||||
r => {
|
||||
accept = app.accept;
|
||||
@@ -1258,11 +1366,39 @@ pub async fn history(
|
||||
results = app.query_results(&mut db, settings.smart_sort).await?;
|
||||
}
|
||||
|
||||
let inspecting_id = app.inspecting_state.clone().current;
|
||||
// If inspecting ID is not the current inspecting History, update it.
|
||||
match inspecting_id {
|
||||
Some(inspecting_id) => {
|
||||
if inspecting.is_none() || inspecting_id != inspecting.clone().unwrap().id {
|
||||
inspecting = db.load(inspecting_id.0.as_str()).await?;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
inspecting = None;
|
||||
}
|
||||
}
|
||||
|
||||
stats = if app.tab_index == 0 {
|
||||
None
|
||||
} else if !results.is_empty() {
|
||||
let selected = results[app.results_state.selected()].clone();
|
||||
Some(db.stats(&selected).await?)
|
||||
// If we have stats, then we can indicate next available IDs. This avoids passing
|
||||
// around a database object, or a full stats object.
|
||||
let selected = match inspecting.clone() {
|
||||
Some(insp) => insp,
|
||||
None => results[app.results_state.selected()].clone(),
|
||||
};
|
||||
let stats = db.stats(&selected).await?;
|
||||
app.inspecting_state.current = Some(selected.id);
|
||||
app.inspecting_state.previous = match stats.previous.clone() {
|
||||
Some(p) => Some(p.id),
|
||||
_ => None,
|
||||
};
|
||||
app.inspecting_state.next = match stats.next.clone() {
|
||||
Some(p) => Some(p.id),
|
||||
_ => None,
|
||||
};
|
||||
Some(stats)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -1275,6 +1411,25 @@ pub async fn history(
|
||||
}
|
||||
|
||||
match result {
|
||||
InputAction::AcceptInspecting => {
|
||||
match inspecting {
|
||||
Some(result) => {
|
||||
let mut command = result.command;
|
||||
if accept
|
||||
&& matches!(
|
||||
Shell::from_env(),
|
||||
Shell::Zsh | Shell::Fish | Shell::Bash | Shell::Xonsh
|
||||
)
|
||||
{
|
||||
command = String::from("__atuin_accept__:") + &command;
|
||||
}
|
||||
|
||||
// index is in bounds so we return that entry
|
||||
Ok(command)
|
||||
}
|
||||
None => Ok(String::new()),
|
||||
}
|
||||
}
|
||||
InputAction::Accept(index) if index < results.len() => {
|
||||
let mut command = results.swap_remove(index).command;
|
||||
|
||||
@@ -1341,7 +1496,7 @@ mod tests {
|
||||
use crate::command::client::search::engines::{self, SearchState};
|
||||
use crate::command::client::search::history_list::ListState;
|
||||
|
||||
use super::State;
|
||||
use super::{Compactness, InspectingState, State};
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
@@ -1410,7 +1565,7 @@ mod tests {
|
||||
&results,
|
||||
0_usize,
|
||||
0_usize,
|
||||
false,
|
||||
Compactness::Full,
|
||||
1,
|
||||
80,
|
||||
);
|
||||
@@ -1420,7 +1575,7 @@ mod tests {
|
||||
&results,
|
||||
1_usize,
|
||||
0_usize,
|
||||
false,
|
||||
Compactness::Full,
|
||||
1,
|
||||
80,
|
||||
);
|
||||
@@ -1430,7 +1585,7 @@ mod tests {
|
||||
&results,
|
||||
2_usize,
|
||||
0_usize,
|
||||
false,
|
||||
Compactness::Full,
|
||||
1,
|
||||
80,
|
||||
);
|
||||
@@ -1440,7 +1595,7 @@ mod tests {
|
||||
&results,
|
||||
0_usize,
|
||||
0_usize,
|
||||
false,
|
||||
Compactness::Full,
|
||||
1,
|
||||
66,
|
||||
);
|
||||
@@ -1450,7 +1605,7 @@ mod tests {
|
||||
&results,
|
||||
2_usize,
|
||||
0_usize,
|
||||
false,
|
||||
Compactness::Full,
|
||||
1,
|
||||
80,
|
||||
);
|
||||
@@ -1460,7 +1615,7 @@ mod tests {
|
||||
&results,
|
||||
1_usize,
|
||||
0_usize,
|
||||
false,
|
||||
Compactness::Full,
|
||||
1,
|
||||
80,
|
||||
);
|
||||
@@ -1470,7 +1625,7 @@ mod tests {
|
||||
&results,
|
||||
1_usize,
|
||||
0_usize,
|
||||
false,
|
||||
Compactness::Full,
|
||||
1,
|
||||
20,
|
||||
);
|
||||
@@ -1480,7 +1635,7 @@ mod tests {
|
||||
&results,
|
||||
1_usize,
|
||||
0_usize,
|
||||
false,
|
||||
Compactness::Full,
|
||||
1,
|
||||
20,
|
||||
);
|
||||
@@ -1512,6 +1667,11 @@ mod tests {
|
||||
prefix: false,
|
||||
current_cursor: None,
|
||||
tab_index: 0,
|
||||
inspecting_state: InspectingState {
|
||||
current: None,
|
||||
next: None,
|
||||
previous: None,
|
||||
},
|
||||
search: SearchState {
|
||||
input: String::new().into(),
|
||||
filter_mode: FilterMode::Directory,
|
||||
@@ -1558,6 +1718,11 @@ mod tests {
|
||||
prefix: false,
|
||||
current_cursor: None,
|
||||
tab_index: 0,
|
||||
inspecting_state: InspectingState {
|
||||
current: None,
|
||||
next: None,
|
||||
previous: None,
|
||||
},
|
||||
search: SearchState {
|
||||
input: String::new().into(),
|
||||
filter_mode: FilterMode::Global,
|
||||
|
||||
Reference in New Issue
Block a user