set envvars

This commit is contained in:
Jana Dönszelmann 2026-02-20 12:40:07 +01:00
commit 3963fc50c3
No known key found for this signature in database
9 changed files with 3248 additions and 0 deletions

594
src/tui/mod.rs Normal file
View file

@ -0,0 +1,594 @@
use ratatui_themes::{Theme, ThemeName};
use std::{
fs::{self, DirEntry},
io,
path::{Path, PathBuf},
process::exit,
};
use tui_widget_list::{ListBuilder, ListView};
use crate::tui::{
filter::{FilterKind, WipMatcher},
log_viewer::LogViewer,
};
use crate::tui::{
filter::{FilterSelection, WipFilter},
reader::LogfileReader,
};
use ratatui::{
DefaultTerminal,
buffer::Buffer,
crossterm::event::{self, Event, KeyCode, KeyModifiers},
layout::{Constraint, HorizontalAlignment, Layout, Rect},
style::Style,
widgets::{
Block, Clear, List, ListItem, ListState, Padding, Paragraph, StatefulWidget, Widget, Wrap,
},
};
pub mod filter;
pub mod log_viewer;
pub mod model;
pub mod reader;
pub fn run(logs_dir: PathBuf) {
let terminal = ratatui::init();
let theme = Theme::new(ThemeName::OneDarkPro);
let app_result = App::new(logs_dir, theme).run(terminal);
ratatui::restore();
if let Err(e) = app_result {
eprintln!("error in tui: {e:?}");
exit(1);
}
}
enum Tab {
FileChooser {
files: Vec<DirEntry>,
state: ListState,
last_height: usize,
},
LogViewer(LogViewer),
CreateFilter {
filter: WipFilter,
},
Empty,
}
impl Tab {
fn name(&self, path: Option<&Path>) -> String {
match (self, path) {
(Tab::Empty, _) => "dummy".to_string(),
(Tab::FileChooser { .. }, _) => "choose a file".to_string(),
(Tab::LogViewer(_), Some(path)) => format!("logs of {}", path.display()),
(Tab::LogViewer(_), None) => "logs".to_string(),
(Tab::CreateFilter { .. }, _) => "create filter".to_string(),
}
}
}
fn initialize_filter(
lv: &mut LogViewer,
file: &mut LogfileReader,
kind: Option<FilterKind>,
) -> WipFilter {
let matcher = if lv.footer_selected {
let footer_fields = lv.footer_fields(file);
let (key, value) = footer_fields
.get(lv.footer_list.selected.unwrap_or(0))
.map_or((None, None), |(k, v)| (Some(k), Some(v)));
Some(WipMatcher::Field {
name: key.cloned(),
value: value.cloned(),
})
} else {
Some(WipMatcher::Specific {
path: Some(lv.path().clone()),
})
};
WipFilter {
matcher,
kind,
selection: filter::FilterSelection::Kind,
}
}
struct App {
tabs: Vec<Tab>,
logs_dir: PathBuf,
current_file: Option<LogfileReader>,
theme: Theme,
}
impl App {
fn new(logs_dir: PathBuf, theme: Theme) -> Self {
let mut res = Self {
tabs: Vec::new(),
current_file: None,
logs_dir,
theme,
};
res.replace_tab(res.choose_file());
res
}
fn current_file_path(&self) -> Option<PathBuf> {
self.current_file.as_ref().map(|i| i.path.to_path_buf())
}
fn replace_tab(&mut self, tab: Tab) {
if let Some(last) = self.tabs.last_mut() {
*last = tab;
} else {
self.tabs = vec![tab];
}
}
fn push_tab(&mut self, tab: Tab) {
self.tabs.push(tab);
}
fn pop_tab(&mut self) {
let _ = self.tabs.pop();
}
fn choose_file(&self) -> Tab {
fn init(logs_dir: &Path) -> io::Result<Vec<DirEntry>> {
let mut files = Vec::new();
for file in fs::read_dir(logs_dir)? {
let file = file?;
files.push(file);
}
Ok(files)
}
match init(&self.logs_dir) {
Ok(files) => Tab::FileChooser {
files,
state: ListState::default(),
last_height: 0,
},
Err(_) => Tab::Empty,
}
}
fn current_tab(&mut self) -> &mut Tab {
if self.tabs.is_empty() {
self.tabs.push(Tab::Empty);
}
self.tabs.last_mut().unwrap()
}
fn run(mut self, mut terminal: DefaultTerminal) -> io::Result<()> {
loop {
terminal.draw(|frame| frame.render_widget(&mut self, frame.area()))?;
if let Event::Key(key) = event::read()? {
// to initialize, but we then do get manually for borrow reasons
self.current_tab();
let num_tabs = self.tabs.len();
match (key.code, self.tabs.last_mut().unwrap()) {
(KeyCode::Char('q'), _) => return Ok(()),
(KeyCode::Char('c'), _) if key.modifiers.contains(KeyModifiers::CONTROL) => {
return Ok(());
}
(KeyCode::Esc, _) if num_tabs > 1 => {
self.pop_tab();
}
(KeyCode::Char('j') | KeyCode::Down, tab) => match tab {
Tab::FileChooser { state, .. } => state.select_next(),
Tab::LogViewer(lv) => lv.next(),
Tab::Empty => {}
Tab::CreateFilter { filter } => {
filter.selection.next();
}
},
(KeyCode::Char('k') | KeyCode::Up, tab) => match tab {
Tab::FileChooser { state, .. } => state.select_previous(),
Tab::LogViewer(lv) => {
lv.prev();
}
Tab::Empty => {}
Tab::CreateFilter { filter } => {
filter.selection.prev();
}
},
(KeyCode::PageDown, tab) => match tab {
Tab::FileChooser {
state, last_height, ..
} => state.scroll_down_by(*last_height as u16),
Tab::LogViewer(lv) => {
lv.page_down();
}
Tab::Empty => {}
Tab::CreateFilter { .. } => {}
},
(KeyCode::PageUp, tab) => match tab {
Tab::FileChooser {
state, last_height, ..
} => state.scroll_up_by(*last_height as u16),
Tab::LogViewer(lv) => {
lv.page_up();
}
Tab::Empty => {}
Tab::CreateFilter { .. } => {}
},
(KeyCode::Char('G') | KeyCode::Home, tab) => match tab {
Tab::FileChooser { state, .. } => state.select_first(),
Tab::LogViewer(lv) => {
lv.home();
}
Tab::Empty => {}
Tab::CreateFilter { .. } => {}
},
(KeyCode::Char('g') | KeyCode::End, tab) => match tab {
Tab::FileChooser { state, .. } => state.select_last(),
Tab::LogViewer(lv) => {}
Tab::Empty => {}
Tab::CreateFilter { .. } => {}
},
(KeyCode::Backspace | KeyCode::Left | KeyCode::Esc, Tab::LogViewer(lv)) => {
lv.back();
}
(KeyCode::Backspace, Tab::CreateFilter { filter }) => {
filter.clear();
}
(KeyCode::Right, Tab::CreateFilter { filter }) => {
filter.right();
}
(KeyCode::Left, Tab::CreateFilter { filter }) => {
filter.left();
}
(KeyCode::Right, Tab::LogViewer(lv)) => {
if let Some(file) = &mut self.current_file {
lv.enter(file)
}
}
(KeyCode::Tab, Tab::LogViewer(lv)) => {
lv.switch_focus();
}
(KeyCode::Char('r'), Tab::LogViewer(lv)) => {
if let Some(file) = &mut self.current_file {
let filter = initialize_filter(lv, file, Some(FilterKind::Remove));
self.push_tab(Tab::CreateFilter { filter });
}
}
(KeyCode::Char('i'), Tab::LogViewer(lv)) => {
if let Some(file) = &mut self.current_file {
let filter = initialize_filter(lv, file, Some(FilterKind::Inline));
self.push_tab(Tab::CreateFilter { filter });
}
}
(KeyCode::Enter, tab) => match tab {
Tab::FileChooser { files, state, .. } => {
if let Some(selected) = state.selected()
&& let Some(selected) = files.get(selected)
{
match LogfileReader::new(&selected.path()) {
Ok(i) => {
self.current_file = Some(i);
self.replace_tab(Tab::LogViewer(LogViewer::new()));
}
Err(e) => {
panic!()
}
}
}
}
Tab::LogViewer(lv) => {
if let Some(file) = &mut self.current_file {
if lv.footer_selected {
let filter = initialize_filter(lv, file, None);
self.push_tab(Tab::CreateFilter { filter });
} else {
lv.enter(file)
}
}
}
Tab::Empty => {}
Tab::CreateFilter { filter } => {
if let FilterSelection::Confirm = filter.selection
&& let Some(file) = &mut self.current_file
{
if let Some(filter) = filter.validate() {
file.add_filter(filter);
self.pop_tab();
if let Tab::LogViewer(lv) = self.current_tab() {
lv.footer_selected = false;
}
}
} else {
filter.selection.next();
}
}
},
_ => {}
}
}
}
}
}
impl Widget for &mut App {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
let palette = self.theme.palette();
let default = Style::new().fg(palette.fg).bg(palette.bg);
let highlighted = Style::new().fg(palette.accent).bg(palette.selection);
let border = Style::new().fg(palette.fg).bg(palette.bg);
let border_selected = Style::new().fg(palette.secondary).bg(palette.bg);
let [header_area, main_area, footer_area] = Layout::vertical([
Constraint::Length(2),
Constraint::Fill(1),
Constraint::Ratio(1, 4),
])
.areas(area);
let [_, popup_area, _] = Layout::vertical([
Constraint::Fill(1),
Constraint::Min(40),
Constraint::Fill(1),
])
.areas(area);
let [_, popup_area, _] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Min(40),
Constraint::Fill(1),
])
.areas(popup_area);
let (footer_focused, header_focused) = match self.current_tab() {
Tab::FileChooser { .. } => (false, true),
Tab::LogViewer(lv) => (lv.footer_selected, !lv.footer_selected),
Tab::Empty => (false, false),
Tab::CreateFilter { .. } => (false, false),
};
let main_area = {
let block = Block::bordered()
.style(default)
.border_style(if header_focused {
border_selected
} else {
border
});
let inner = block.inner(main_area);
block.render(main_area, buf);
inner
};
let footer_area = {
let block = Block::bordered()
.style(default)
.border_style(if footer_focused {
border_selected
} else {
border
});
let inner = block.inner(footer_area);
block.render(footer_area, buf);
inner
};
let [left, middle, right] = Layout::horizontal([
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Fill(1),
])
.areas(header_area);
let current_file_path = self.current_file_path();
let breadcrumbs = self.tabs[..self.tabs.len() - 1]
.iter()
.map(|i| i.name(current_file_path.as_deref()))
.collect::<Vec<_>>()
.join("");
Paragraph::new(breadcrumbs)
.wrap(Wrap { trim: false })
.render(left, buf);
Paragraph::new(self.current_tab().name(current_file_path.as_deref()))
.alignment(HorizontalAlignment::Center)
.wrap(Wrap { trim: false })
.render(middle, buf);
for tab in &mut self.tabs {
match tab {
Tab::FileChooser {
files,
state,
last_height,
} => {
let list = List::new(files.iter().map(|file| {
ListItem::new(file.file_name().to_string_lossy().into_owned())
}))
.style(default)
.highlight_style(highlighted);
*last_height = main_area.height as usize;
StatefulWidget::render(list, main_area, buf, state);
}
Tab::LogViewer(lv) => {
let Some(file) = &mut self.current_file else {
continue;
};
lv.update_num_items(main_area.height as usize);
let (items, start, selected) = lv
.items(file, main_area.height as usize)
.unwrap_or_else(|| (Vec::new(), 0, 0));
let list = List::new(items.into_iter().enumerate().map(|(idx, i)| {
let line = i.line_text(false);
let mut list_item = ListItem::new(line);
if idx + start == selected {
list_item = list_item.style(highlighted);
}
list_item
}));
Widget::render(list, main_area, buf);
let items = lv.footer_fields(file);
let width = 20;
let builder = ListBuilder::new(|cx| {
let Some((k, v)) = &items.get(cx.index) else {
return (Paragraph::new(""), 1);
};
let contents = serde_json::to_string_pretty(&v)
.unwrap_or(String::new())
.replace("\n", "\n{:width$}");
let mut res = Paragraph::new(format!("{k:width$} {contents}"))
.wrap(Wrap { trim: false });
if cx.is_selected {
res = res.style(highlighted);
}
let height = res.line_count(footer_area.width) as u16;
(res, height)
});
let list = ListView::new(builder, items.len());
StatefulWidget::render(list, footer_area, buf, &mut lv.footer_list);
}
Tab::Empty => {}
Tab::CreateFilter { filter } => {
Clear.render(popup_area, buf);
let popup_area = {
let block = Block::bordered()
.title_top("create filter")
.style(default)
.padding(Padding::symmetric(3, 1))
.border_style(border_selected);
let inner = block.inner(popup_area);
block.render(popup_area, buf);
inner
};
let [kind, matcher_kind, matcher_area, confirm] = Layout::vertical([
Constraint::Length(5),
Constraint::Length(5),
Constraint::Fill(1),
Constraint::Length(1),
])
.areas(popup_area);
let text = match &filter.kind {
None => "<unset>",
Some(FilterKind::Inline) => "inline items",
Some(FilterKind::Remove) => "remove item and sub-items",
};
Paragraph::new(format!("{text}"))
.centered()
.style(
if matches!(filter.selection, filter::FilterSelection::Kind) {
highlighted
} else {
default
},
)
.block(
Block::bordered()
.title_top("transformation")
.padding(Padding::uniform(1)),
)
.render(kind, buf);
let text = match filter.matcher.as_ref() {
None => "<unset>",
Some(WipMatcher::Field { .. }) => "all logs where field matches",
Some(WipMatcher::Specific { .. }) => "this specific log",
};
Paragraph::new(format!("{text}"))
.centered()
.style(
if matches!(filter.selection, filter::FilterSelection::MatcherKind) {
highlighted
} else {
default
},
)
.block(
Block::bordered()
.title_top("matcher")
.padding(Padding::uniform(1)),
)
.render(matcher_kind, buf);
match &filter.matcher {
Some(WipMatcher::Field { name, value }) => {
let [field_area, value_area, _] = Layout::vertical([
Constraint::Length(5),
Constraint::Length(5),
Constraint::Fill(1),
])
.areas(matcher_area);
Paragraph::new(format!("{}", name.clone().unwrap_or_default()))
.centered()
.style(
if matches!(filter.selection, filter::FilterSelection::Matcher)
{
highlighted
} else {
default
},
)
.block(
Block::bordered()
.title_top("field name")
.padding(Padding::uniform(1)),
)
.render(field_area, buf);
Paragraph::new(format!("{}", value.clone().unwrap_or_default()))
.centered()
.style(
if matches!(filter.selection, filter::FilterSelection::Matcher)
{
highlighted
} else {
default
},
)
.block(
Block::bordered()
.title_top("value")
.padding(Padding::uniform(1)),
)
.render(value_area, buf);
}
Some(WipMatcher::Specific { .. }) => {}
None => {}
}
Paragraph::new("confirm")
.centered()
.style(
if matches!(filter.selection, filter::FilterSelection::Confirm) {
highlighted
} else {
default
},
)
.render(confirm, buf);
}
}
}
}
}