undo/redo

This commit is contained in:
Jana Dönszelmann 2026-02-25 14:29:28 +01:00
parent 8a4df3307d
commit 8cfe1a0b65
No known key found for this signature in database
8 changed files with 247 additions and 154 deletions

View file

@ -13,7 +13,7 @@ jiff = {version = "0.2", features = ["serde"]}
ratatui = {version = "0.30.0", features=["unstable-rendered-line-info"]}
ratatui-themes = { version = "0.2", features = ["serde"] }
tui-widget-list = "0.15"
serde = {version = "1", features = ["derive"]}
serde = {version = "1", features = ["derive", "rc"]}
serde_json = "1"
thiserror = "2"
itertools = "0.14"

View file

@ -1,13 +1,34 @@
use regex::bytes::Regex;
use serde::{Deserialize, Serialize};
use crate::tui::{
log_viewer::{FieldMatcher, InputTarget, LogViewer},
log_viewer::{
LogViewer,
input::{FieldMatcher, InputTarget},
},
model::LogEntry,
};
mod serialize_regex {
use regex::bytes::Regex;
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error};
pub fn serialize<S: Serializer>(r: &Regex, serializer: S) -> Result<S::Ok, S::Error> {
r.as_str().serialize(serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Regex, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Regex::new(&s).map_err(|e| D::Error::custom(e.to_string()))
}
}
#[derive(Serialize, Deserialize)]
pub enum MatcherValue {
Exact(String),
Regex(Regex),
Regex(#[serde(with = "serialize_regex")] Regex),
Prefix(String),
Contains(String),
}
@ -32,6 +53,7 @@ impl MatcherValue {
}
}
#[derive(Serialize, Deserialize)]
pub enum Matcher {
Field { name: String, value: MatcherValue },
Message { value: MatcherValue },
@ -76,12 +98,13 @@ impl Matcher {
}
}
#[derive(Clone)]
#[derive(Clone, Serialize, Deserialize)]
pub enum FilterKind {
Inline,
Remove,
}
#[derive(Serialize, Deserialize)]
pub struct Filter {
pub matcher: Matcher,
pub kind: FilterKind,

View file

@ -0,0 +1,41 @@
use std::rc::Rc;
use serde::{Deserialize, Serialize};
use crate::tui::filter::Filter;
#[derive(Serialize, Deserialize)]
pub struct Filters {
filters: Vec<Rc<Filter>>,
undo_pos: usize,
}
impl Filters {
pub fn new() -> Self {
Self {
filters: Vec::new(),
undo_pos: 0,
}
}
pub fn get(&self) -> &[Rc<Filter>] {
&self.filters[0..self.undo_pos]
}
pub fn push(&mut self, filter: Rc<Filter>) {
self.filters.truncate(self.undo_pos);
self.filters.push(filter);
self.undo_pos = self.filters.len();
}
pub fn redo(&mut self) {
self.undo_pos += 1;
if self.undo_pos > self.filters.len() {
self.undo_pos = self.filters.len()
}
}
pub fn undo(&mut self) {
self.undo_pos = self.undo_pos.saturating_sub(1);
}
}

109
src/tui/log_viewer/input.rs Normal file
View file

@ -0,0 +1,109 @@
use std::mem;
use ratatui::{buffer::Buffer, layout::Rect, text::Line, widgets::Widget};
use crate::tui::widgets::styled::Styled;
#[derive(Clone)]
pub enum FieldMatcher {
EqualTo,
Prefix(String),
Regex(String),
Contains(String),
}
impl FieldMatcher {
pub fn show(&self) -> String {
match self {
FieldMatcher::EqualTo => "equal to selected value".to_string(),
Self::Prefix(s) => format!("with a prefix of `{s}`"),
Self::Regex(s) => format!("matching /{s}/"),
Self::Contains(s) => format!("containing `{s}`"),
}
}
}
#[derive(Clone)]
pub enum InputTarget {
Fields(Option<FieldMatcher>),
Text(FieldMatcher),
This,
Surround,
}
impl InputTarget {
pub fn show(&self) -> String {
match self {
Self::Fields(None) => "logs with a field...".to_string(),
Self::Fields(Some(fm)) => format!("logs with the selected field {}", fm.show()),
Self::Text(fm) => format!("logs {}", fm.show()),
Self::This => format!("this log"),
Self::Surround => format!("the log surrounding the current view"),
}
}
}
#[derive(Clone)]
pub enum InputState {
None,
Target(InputTarget),
}
impl Widget for Styled<'_, &InputState> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
Line::from(self.inner.show())
.style(self.styles.default)
.render(area, buf)
}
}
impl InputState {
pub fn capture_string(&mut self) -> Option<&mut String> {
match self {
InputState::None => None,
InputState::Target(InputTarget::This) => None,
InputState::Target(InputTarget::Fields(None)) => None,
InputState::Target(InputTarget::Surround) => None,
// require arbitrary text input
InputState::Target(InputTarget::Fields(Some(FieldMatcher::Contains(s)))) => Some(s),
InputState::Target(InputTarget::Text(FieldMatcher::Contains(s))) => Some(s),
InputState::Target(InputTarget::Fields(Some(FieldMatcher::Prefix(s)))) => Some(s),
InputState::Target(InputTarget::Text(FieldMatcher::Prefix(s))) => Some(s),
InputState::Target(InputTarget::Fields(Some(FieldMatcher::Regex(s)))) => Some(s),
InputState::Target(InputTarget::Text(FieldMatcher::Regex(s))) => Some(s),
InputState::Target(InputTarget::Fields(Some(FieldMatcher::EqualTo))) => None,
InputState::Target(InputTarget::Text(FieldMatcher::EqualTo)) => None,
}
}
pub fn captures_input(&mut self) -> bool {
self.capture_string().is_some()
}
pub fn show(&self) -> String {
match self {
InputState::None => "".to_string(),
InputState::Target(input_target) => input_target.show(),
}
}
pub fn reset(&mut self) {
*self = Self::None;
}
pub fn target(&mut self, target: InputTarget) {
if let Self::Target(t) = self
&& mem::discriminant(t) == mem::discriminant(&target)
&& !self.captures_input()
{
self.reset();
} else {
*self = Self::Target(target);
}
}
}

View file

@ -2,147 +2,24 @@ use std::{collections::HashMap, iter, mem, rc::Rc};
use crate::tui::{
filter::Filter,
log_viewer::{
filters::Filters,
input::{FieldMatcher, InputState, InputTarget},
view::LogView,
},
model::LogEntry,
processing::{IntoLogStream, LogStream},
widgets::styled::Styled,
};
use ratatui::{buffer::Buffer, layout::Rect, text::Line, widgets::Widget};
use tui_widget_list::ListState;
pub struct LogView {
iter: Box<dyn LogStream>,
selection_offset: usize,
}
impl LogView {
pub fn selected(&self) -> Option<(Rc<LogEntry>, usize)> {
let mut temp_iter = self.iter.clone();
for _ in 0..self.selection_offset {
let _ = temp_iter.next()?;
}
temp_iter.next()
}
}
impl Clone for LogView {
fn clone(&self) -> Self {
Self {
iter: self.iter.clone(),
selection_offset: self.selection_offset.clone(),
}
}
}
#[derive(Clone)]
pub enum FieldMatcher {
EqualTo,
Prefix(String),
Regex(String),
Contains(String),
}
impl FieldMatcher {
pub fn show(&self) -> String {
match self {
FieldMatcher::EqualTo => "equal to selected value".to_string(),
Self::Prefix(s) => format!("with a prefix of `{s}`"),
Self::Regex(s) => format!("matching /{s}/"),
Self::Contains(s) => format!("containing `{s}`"),
}
}
}
#[derive(Clone)]
pub enum InputTarget {
Fields(Option<FieldMatcher>),
Text(FieldMatcher),
This,
Surround,
}
impl InputTarget {
pub fn show(&self) -> String {
match self {
Self::Fields(None) => "logs with a field...".to_string(),
Self::Fields(Some(fm)) => format!("logs with the selected field {}", fm.show()),
Self::Text(fm) => format!("logs {}", fm.show()),
Self::This => format!("this log"),
Self::Surround => format!("the log surrounding the current view"),
}
}
}
#[derive(Clone)]
pub enum InputState {
None,
Target(InputTarget),
}
impl Widget for Styled<'_, &InputState> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
Line::from(self.inner.show())
.style(self.styles.default)
.render(area, buf)
}
}
impl InputState {
pub fn capture_string(&mut self) -> Option<&mut String> {
match self {
InputState::None => None,
InputState::Target(InputTarget::This) => None,
InputState::Target(InputTarget::Fields(None)) => None,
InputState::Target(InputTarget::Surround) => None,
// require arbitrary text input
InputState::Target(InputTarget::Fields(Some(FieldMatcher::Contains(s)))) => Some(s),
InputState::Target(InputTarget::Text(FieldMatcher::Contains(s))) => Some(s),
InputState::Target(InputTarget::Fields(Some(FieldMatcher::Prefix(s)))) => Some(s),
InputState::Target(InputTarget::Text(FieldMatcher::Prefix(s))) => Some(s),
InputState::Target(InputTarget::Fields(Some(FieldMatcher::Regex(s)))) => Some(s),
InputState::Target(InputTarget::Text(FieldMatcher::Regex(s))) => Some(s),
InputState::Target(InputTarget::Fields(Some(FieldMatcher::EqualTo))) => None,
InputState::Target(InputTarget::Text(FieldMatcher::EqualTo)) => None,
}
}
pub fn captures_input(&mut self) -> bool {
self.capture_string().is_some()
}
pub fn show(&self) -> String {
match self {
InputState::None => "".to_string(),
InputState::Target(input_target) => input_target.show(),
}
}
pub fn reset(&mut self) {
*self = Self::None;
}
pub fn target(&mut self, target: InputTarget) {
if let Self::Target(t) = self
&& mem::discriminant(t) == mem::discriminant(&target)
&& !self.captures_input()
{
self.reset();
} else {
*self = Self::Target(target);
}
}
}
pub mod filters;
pub mod input;
pub mod view;
pub struct LogViewer {
pub stack: Vec<LogView>,
curr: LogView,
cache: HashMap<Vec<usize>, LogView>,
filters: Vec<Rc<Filter>>,
pub root_stream: Box<dyn LogStream>,
@ -152,7 +29,7 @@ pub struct LogViewer {
pub last_fields_height: usize,
pub footer_list: ListState,
filters: Filters,
pub input_state: InputState,
}
@ -173,22 +50,21 @@ impl LogViewer {
last_fields_offset: 0,
last_fields_height: 0,
filters: Vec::new(),
filters: Filters::new(),
input_state: InputState::None,
}
}
pub fn filtered_root_stream(&self) -> Box<dyn LogStream> {
let mut curr = self.root_stream.clone();
for filter in &self.filters {
for filter in self.filters.get() {
curr = Box::new(curr.filter(Rc::clone(filter)));
}
curr
}
pub fn add_filter(&mut self, filter: Rc<Filter>) {
self.filters.push(Rc::clone(&filter));
fn update_filters(&mut self) {
self.cache.clear();
let offsets_list: Vec<_> = self
.stack
@ -230,9 +106,9 @@ impl LogViewer {
// If the value we're looking for is removed by the filter,
// we'll have a hard time finding it so quit
if filter.removes(&elem) {
break;
}
// if filter.removes(&elem) {
// break;
// }
// find the nearest stream in which this element can be found
let mut curr = current_stream.as_ref();
@ -286,6 +162,11 @@ impl LogViewer {
self.stack = new_stack;
}
pub fn add_filter(&mut self, filter: Rc<Filter>) {
self.filters.push(Rc::clone(&filter));
self.update_filters();
}
pub fn update_num_items(&mut self, num_visible_items: usize) {
while self.curr.selection_offset >= num_visible_items {
if self.curr.selection_offset == 0 {
@ -436,6 +317,16 @@ impl LogViewer {
self.input_state.reset();
}
pub fn undo(&mut self) {
self.filters.undo();
self.update_filters();
}
pub fn redo(&mut self) {
self.filters.redo();
self.update_filters();
}
pub fn enter(&mut self) {
match self.input_state {
InputState::None => {

View file

@ -0,0 +1,28 @@
use std::rc::Rc;
use crate::tui::{model::LogEntry, processing::LogStream};
pub struct LogView {
pub iter: Box<dyn LogStream>,
pub selection_offset: usize,
}
impl LogView {
pub fn selected(&self) -> Option<(Rc<LogEntry>, usize)> {
let mut temp_iter = self.iter.clone();
for _ in 0..self.selection_offset {
let _ = temp_iter.next()?;
}
temp_iter.next()
}
}
impl Clone for LogView {
fn clone(&self) -> Self {
Self {
iter: self.iter.clone(),
selection_offset: self.selection_offset.clone(),
}
}
}

View file

@ -14,15 +14,13 @@ use std::{
use tui_widget_list::{ListBuilder, ListView};
use crate::tui::{
filter::FilterKind,
log_viewer::{InputState, InputTarget, LogViewer},
widgets::{hyperlink::Hyperlink, items::Items, last_error::LastError},
};
use crate::tui::{
filter::{Filter, Matcher},
log_viewer::FieldMatcher,
filter::{Filter, FilterKind, Matcher},
log_viewer::{
LogViewer,
input::{FieldMatcher, InputState, InputTarget},
},
reader::LogfileReader,
widgets::styled::IntoStyled,
widgets::{hyperlink::Hyperlink, items::Items, last_error::LastError, styled::IntoStyled},
};
use ratatui::{
DefaultTerminal, Terminal,
@ -67,7 +65,6 @@ targeting logs:
either a field after `f` or the text of the current log:
p ... with a prefix
r ... matching a regex
/ ... matching a regex
e ... equal to selected
c ... containing
@ -298,6 +295,10 @@ impl App {
KeyCode::Backspace | KeyCode::Left => lv.back(),
KeyCode::Right => lv.enter(),
KeyCode::Enter => lv.enter(),
KeyCode::Char('u') => lv.undo(),
KeyCode::Char('r') => lv.redo(),
KeyCode::Char('f') => {
lv.input_state.target(InputTarget::Fields(None));
lv.footer_list.select(Some(0));
@ -309,7 +310,7 @@ impl App {
KeyCode::Char('t') => {
lv.input_state.target(InputTarget::This);
}
KeyCode::Char('r') | KeyCode::Char('/') => {
KeyCode::Char('/') => {
let v = FieldMatcher::Regex(String::new());
if let InputState::Target(InputTarget::Fields(f @ None)) = &mut lv.input_state {
*f = Some(v);

View file

@ -5,7 +5,7 @@ use ratatui::widgets::{List, ListItem, Widget};
use regex::Regex;
use crate::tui::{
log_viewer::{FieldMatcher, InputState, InputTarget},
log_viewer::input::{FieldMatcher, InputState, InputTarget},
model::LogEntry,
widgets::{
last_error::LastError,