Compare commits
27 Commits
13d72a5c11
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8affad428e | |||
| ef132c35f3 | |||
| e6c30dc403 | |||
| f9f81e6dcf | |||
| e1102b8590 | |||
| 7d3f95ae82 | |||
| 42d0c0c02a | |||
| 22af721701 | |||
| bd56cd31e4 | |||
| 5f8facbd46 | |||
| 3d3ca9a81b | |||
| 60c2c288c5 | |||
| 0af31b2349 | |||
| 5681a74985 | |||
| cb4e12fe86 | |||
| d9625c7fdd | |||
| aa692808fd | |||
| b648bf9dbe | |||
| 2872a2327d | |||
| 3e7555f496 | |||
| a9eb30efca | |||
| 9859008cdb | |||
| 34c09c4e89 | |||
| 248ec1e8f5 | |||
| c70cf7e6f0 | |||
| 12cd121e3b | |||
| f370821cfe |
81
.gitea/workflows/build.yml
Normal file
81
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,81 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build - ${{ matrix.platform }}
|
||||
runs-on: gitea-runner
|
||||
container:
|
||||
image: ${{ matrix.docker_image }}
|
||||
volumes:
|
||||
- /tmp/gitea-sccache:/tmp/sccache
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux
|
||||
docker_image: gitea.staspast.click/stas/rust-node-builder:v5
|
||||
target: x86_64-unknown-linux-gnu
|
||||
artifact_name: rlogg-linux-x86_64
|
||||
binary_extension: ""
|
||||
setup_cmd: ""
|
||||
- platform: windows
|
||||
docker_image: gitea.staspast.click/stas/rust-node-builder:v5
|
||||
target: x86_64-pc-windows-gnu
|
||||
artifact_name: rlogg-windows-x86_64
|
||||
binary_extension: ".exe"
|
||||
setup_cmd: ""
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure sccache
|
||||
run: |
|
||||
# Configure sccache cache directory (persists on runner between builds)
|
||||
mkdir -p /tmp/sccache
|
||||
echo "SCCACHE_DIR=/tmp/sccache" >> $GITHUB_ENV
|
||||
echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup cross-compilation tools
|
||||
if: matrix.setup_cmd != ''
|
||||
run: ${{ matrix.setup_cmd }}
|
||||
|
||||
- name: Add Rust target
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Build release binary
|
||||
run: |
|
||||
if [ "${{ matrix.platform }}" = "macos" ]; then
|
||||
cargo zigbuild --release --target ${{ matrix.target }}
|
||||
elif [ "${{ matrix.platform }}" = "linux" ]; then
|
||||
cargo build --release
|
||||
else
|
||||
# Cross-compile for other targets (e.g., Windows)
|
||||
cargo build --release --target ${{ matrix.target }}
|
||||
fi
|
||||
|
||||
# Show sccache statistics
|
||||
echo "=== sccache stats ==="
|
||||
sccache --show-stats
|
||||
|
||||
- name: Prepare artifact
|
||||
shell: sh
|
||||
run: |
|
||||
mkdir -p dist
|
||||
if [ "${{ matrix.platform }}" = "linux" ]; then
|
||||
cp target/release/rlogg${{ matrix.binary_extension }} dist/${{ matrix.artifact_name }}${{ matrix.binary_extension }}
|
||||
else
|
||||
cp target/${{ matrix.target }}/release/rlogg${{ matrix.binary_extension }} dist/${{ matrix.artifact_name }}${{ matrix.binary_extension }}
|
||||
fi
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ matrix.artifact_name }}
|
||||
path: dist/${{ matrix.artifact_name }}${{ matrix.binary_extension }}
|
||||
retention-days: 14
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -2706,7 +2706,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rlogg"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"eframe",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rlogg"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
edition = "2024"
|
||||
authors = ["Stanislav Pastushenko <staspast1@gmail.com>"]
|
||||
description = "A fast log file viewer with search, filtering, and highlighting capabilities"
|
||||
@@ -18,3 +18,6 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
rayon = "1.10"
|
||||
chrono = "0.4"
|
||||
|
||||
[profile.release]
|
||||
debug = false
|
||||
1
docker/build-linux-image.sh
Executable file
1
docker/build-linux-image.sh
Executable file
@@ -0,0 +1 @@
|
||||
docker build -t gitea.staspast.click/stas/rust-node-builder:v5 -f linux-build.Dockerfile . && docker push gitea.staspast.click/stas/rust-node-builder:v5
|
||||
26
docker/linux-build.Dockerfile
Normal file
26
docker/linux-build.Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM rust:latest
|
||||
|
||||
# Install system dependencies and tools
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
nodejs \
|
||||
npm \
|
||||
tar \
|
||||
gzip \
|
||||
curl \
|
||||
mingw-w64 \
|
||||
&& rm -rf /var/lib/apt/lists/* && \
|
||||
# Check if /bin/tar is BusyBox and replace it with GNU tar
|
||||
if /bin/tar --version 2>&1 | grep -q "BusyBox"; then \
|
||||
cp /usr/bin/tar /bin/tar.gnu && \
|
||||
rm -f /bin/tar && \
|
||||
mv /bin/tar.gnu /bin/tar; \
|
||||
fi
|
||||
|
||||
# Install sccache for Rust compilation caching
|
||||
RUN SCCACHE_VERSION=0.7.4 && \
|
||||
SCCACHE_URL="https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" && \
|
||||
curl -L "$SCCACHE_URL" | tar xz && \
|
||||
chmod +x sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl/sccache && \
|
||||
mv sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl/sccache /usr/local/bin/ && \
|
||||
rm -rf sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl
|
||||
359
src/log_viewer_app.rs
Normal file
359
src/log_viewer_app.rs
Normal file
@@ -0,0 +1,359 @@
|
||||
use std::sync::Arc;
|
||||
use eframe::egui;
|
||||
use crate::config::AppConfig;
|
||||
use crate::file_tab::FileTab;
|
||||
use crate::highlight::HighlightManager;
|
||||
use crate::search::{add_to_history, start_search, SearchParams, SearchState};
|
||||
use crate::tab_manager::{close_tab, open_file_dialog, IndexingState};
|
||||
use crate::ui::{render_highlight_editor, render_search_panel, render_tabs_panel, render_top_menu, SearchPanelState};
|
||||
use crate::ui::log_panel::{render_filter_panel, render_main_log_panel};
|
||||
|
||||
pub struct LogViewerApp {
|
||||
tabs: Vec<FileTab>,
|
||||
active_tab_index: usize,
|
||||
search_panel_state: SearchPanelState,
|
||||
indexing_state: IndexingState,
|
||||
search_state: SearchState,
|
||||
highlight_manager: HighlightManager,
|
||||
first_frame: bool,
|
||||
}
|
||||
|
||||
struct KeyAction {
|
||||
focus_search_input: bool,
|
||||
execute_search: bool
|
||||
}
|
||||
|
||||
impl KeyAction {
|
||||
|
||||
pub fn new() -> Self {
|
||||
Self{
|
||||
focus_search_input: false,
|
||||
execute_search: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LogViewerApp {
|
||||
pub fn new(config: AppConfig) -> Self {
|
||||
Self {
|
||||
tabs: Vec::new(),
|
||||
active_tab_index: 0,
|
||||
search_panel_state: SearchPanelState {
|
||||
query: config.last_search_query,
|
||||
case_sensitive: config.case_sensitive,
|
||||
use_regex: config.use_regex,
|
||||
history: config.search_history,
|
||||
date_range_enabled: config.date_range_enabled,
|
||||
date_format: config.date_format,
|
||||
date_from: config.date_from,
|
||||
date_to: config.date_to,
|
||||
},
|
||||
indexing_state: IndexingState::new(),
|
||||
search_state: SearchState::new(),
|
||||
highlight_manager: HighlightManager::new(config.highlight_rules),
|
||||
first_frame: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn active_tab(&self) -> Option<&FileTab> {
|
||||
self.tabs.get(self.active_tab_index)
|
||||
}
|
||||
|
||||
fn active_tab_mut(&mut self) -> Option<&mut FileTab> {
|
||||
self.tabs.get_mut(self.active_tab_index)
|
||||
}
|
||||
|
||||
fn save_config(&self) {
|
||||
let config = AppConfig {
|
||||
search_history: self.search_panel_state.history.clone(),
|
||||
case_sensitive: self.search_panel_state.case_sensitive,
|
||||
use_regex: self.search_panel_state.use_regex,
|
||||
last_search_query: self.search_panel_state.query.clone(),
|
||||
highlight_rules: self.highlight_manager.rules.clone(),
|
||||
date_range_enabled: self.search_panel_state.date_range_enabled,
|
||||
date_format: self.search_panel_state.date_format.clone(),
|
||||
date_from: self.search_panel_state.date_from.clone(),
|
||||
date_to: self.search_panel_state.date_to.clone(),
|
||||
};
|
||||
config.save();
|
||||
}
|
||||
|
||||
fn handle_open_file(&mut self, open_file_requested: bool) {
|
||||
if open_file_requested && let Some(new_tab) = open_file_dialog(&self.indexing_state) {
|
||||
self.tabs.push(new_tab);
|
||||
self.active_tab_index = self.tabs.len() - 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_close_tab(&mut self, index: usize) {
|
||||
close_tab(&mut self.tabs, &mut self.active_tab_index, index);
|
||||
}
|
||||
|
||||
fn handle_search(&mut self) {
|
||||
if let Some(tab) = self.active_tab() {
|
||||
// Validate date format if date range is enabled
|
||||
if self.search_panel_state.date_range_enabled {
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
// Validate format by trying to parse example dates
|
||||
let test_date = "2025-01-01 00:00:00";
|
||||
if NaiveDateTime::parse_from_str(test_date, &self.search_panel_state.date_format).is_err() {
|
||||
eprintln!("Invalid date format: {}", self.search_panel_state.date_format);
|
||||
eprintln!("Expected format like: %Y-%m-%d %H:%M:%S");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate date_from and date_to can be parsed
|
||||
if !self.search_panel_state.date_from.is_empty() {
|
||||
if NaiveDateTime::parse_from_str(&self.search_panel_state.date_from, &self.search_panel_state.date_format).is_err() {
|
||||
eprintln!("Invalid date_from format. Expected format: {}", self.search_panel_state.date_format);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if !self.search_panel_state.date_to.is_empty() {
|
||||
if NaiveDateTime::parse_from_str(&self.search_panel_state.date_to, &self.search_panel_state.date_format).is_err() {
|
||||
eprintln!("Invalid date_to format. Expected format: {}", self.search_panel_state.date_format);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let params = SearchParams {
|
||||
query: self.search_panel_state.query.clone(),
|
||||
case_sensitive: self.search_panel_state.case_sensitive,
|
||||
use_regex: self.search_panel_state.use_regex,
|
||||
date_range_enabled: self.search_panel_state.date_range_enabled,
|
||||
date_format: self.search_panel_state.date_format.clone(),
|
||||
date_from: self.search_panel_state.date_from.clone(),
|
||||
date_to: self.search_panel_state.date_to.clone(),
|
||||
};
|
||||
|
||||
let line_index = Arc::clone(&tab.line_index);
|
||||
let file_path = tab.file_path.clone();
|
||||
|
||||
// Clear current results
|
||||
if let Some(tab) = self.active_tab_mut() {
|
||||
tab.filtered_lines.clear();
|
||||
}
|
||||
|
||||
start_search(&self.search_state, params, line_index, file_path);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_clear_search(&mut self) {
|
||||
self.search_panel_state.query.clear();
|
||||
if let Some(tab) = self.active_tab_mut() {
|
||||
tab.filtered_lines.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_export_filtered(&mut self) {
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::time::SystemTime;
|
||||
|
||||
if let Some(tab) = self.active_tab() {
|
||||
if tab.filtered_lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate timestamped filename
|
||||
let original_path = &tab.file_path;
|
||||
let file_stem = original_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("export");
|
||||
let extension = original_path
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("log");
|
||||
|
||||
// Generate timestamp
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
// Create new filename: filename_timestamp.extension
|
||||
let export_filename = format!("{}_{}.{}", file_stem, timestamp, extension);
|
||||
let export_path = original_path.with_file_name(export_filename);
|
||||
|
||||
// Write filtered lines to file
|
||||
match File::create(&export_path) {
|
||||
Ok(mut file) => {
|
||||
// Get the line index and file handle
|
||||
let line_index = Arc::clone(&tab.line_index);
|
||||
let file_path = tab.file_path.clone();
|
||||
|
||||
// Open the original file for reading
|
||||
if let Ok(original_file) = File::open(&file_path) {
|
||||
let mut reader = std::io::BufReader::new(original_file);
|
||||
|
||||
// Write each filtered line to the export file
|
||||
for filtered_line in &tab.filtered_lines {
|
||||
if let Some(content) = line_index.read_line(&mut reader, filtered_line.line_number) {
|
||||
writeln!(file, "{}", content).ok();
|
||||
}
|
||||
}
|
||||
|
||||
// Open the exported file in a new tab
|
||||
if let Ok(line_index) = crate::line_index::LineIndex::build(&export_path) {
|
||||
if let Ok(exported_file) = File::open(&export_path) {
|
||||
let new_tab = crate::file_tab::FileTab::new(
|
||||
export_path,
|
||||
line_index,
|
||||
exported_file,
|
||||
);
|
||||
self.tabs.push(new_tab);
|
||||
self.active_tab_index = self.tabs.len() - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to create export file: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_keyboard_input(&mut self, ctx: &egui::Context) {
|
||||
if let Some(tab) = self.active_tab_mut() {
|
||||
tab.page_scroll_direction = ctx.input(|i| {
|
||||
if i.key_pressed(egui::Key::PageDown) {
|
||||
Some(1.0)
|
||||
} else if i.key_pressed(egui::Key::PageUp) {
|
||||
Some(-1.0)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn update_search_results(&mut self) {
|
||||
if let Some(filtered) = self.search_state.take_results() {
|
||||
if let Some(tab) = self.active_tab_mut() {
|
||||
tab.filtered_lines = filtered;
|
||||
tab.filtered_scroll_offset = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_first_frame(&mut self, ctx: &egui::Context) {
|
||||
if self.first_frame {
|
||||
self.first_frame = false;
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_key_inputs(ctx: &egui::Context) -> KeyAction {
|
||||
let mut key_action = KeyAction::new();
|
||||
|
||||
key_action.focus_search_input = ctx.input(|i| {
|
||||
i.modifiers.ctrl && i.key_pressed(egui::Key::F)
|
||||
});
|
||||
|
||||
// Check for Ctrl+Enter to execute search globally
|
||||
key_action.execute_search = ctx.input(|i| {
|
||||
i.modifiers.ctrl && i.key_pressed(egui::Key::Enter)
|
||||
});
|
||||
|
||||
key_action
|
||||
}
|
||||
|
||||
impl eframe::App for LogViewerApp {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
|
||||
self.handle_first_frame(ctx);
|
||||
|
||||
// Update search results if available
|
||||
self.update_search_results();
|
||||
|
||||
// Render top menu
|
||||
let top_menu_actions = render_top_menu(
|
||||
ctx,
|
||||
&mut self.highlight_manager,
|
||||
&self.indexing_state
|
||||
);
|
||||
|
||||
self.handle_open_file(top_menu_actions.open_file_requested);
|
||||
|
||||
// Render highlight editor
|
||||
let highlight_config_changed = render_highlight_editor(ctx, &mut self.highlight_manager);
|
||||
if highlight_config_changed {
|
||||
self.save_config();
|
||||
}
|
||||
|
||||
// Render tabs
|
||||
let mut close_tab_index = None;
|
||||
render_tabs_panel(
|
||||
ctx,
|
||||
&self.tabs,
|
||||
&mut self.active_tab_index,
|
||||
&mut close_tab_index,
|
||||
);
|
||||
|
||||
if let Some(index) = close_tab_index {
|
||||
self.handle_close_tab(index);
|
||||
}
|
||||
|
||||
let keyboard_action = handle_key_inputs(ctx);
|
||||
|
||||
// Render search panel
|
||||
let match_count = self.active_tab().map(|t| t.filtered_lines.len()).unwrap_or(0);
|
||||
let search_actions = render_search_panel(
|
||||
ctx,
|
||||
&mut self.search_panel_state,
|
||||
&self.search_state,
|
||||
match_count,
|
||||
keyboard_action.focus_search_input,
|
||||
);
|
||||
|
||||
if search_actions.execute_search || keyboard_action.focus_search_input {
|
||||
add_to_history(
|
||||
&mut self.search_panel_state.history,
|
||||
&self.search_panel_state.query,
|
||||
);
|
||||
self.handle_search();
|
||||
}
|
||||
|
||||
if search_actions.clear_search {
|
||||
self.handle_clear_search();
|
||||
}
|
||||
|
||||
if search_actions.config_changed {
|
||||
self.save_config();
|
||||
}
|
||||
|
||||
// Render filtered view with improved styling
|
||||
// Show filtered view if there's a query OR if date range is enabled
|
||||
let show_filtered = !self.search_panel_state.query.is_empty() || self.search_panel_state.date_range_enabled;
|
||||
let highlight_rules = self.highlight_manager.rules.clone();
|
||||
|
||||
let mut export_clicked = false;
|
||||
if show_filtered {
|
||||
if let Some(tab) = self.active_tab_mut() {
|
||||
render_filter_panel(tab, ctx, &highlight_rules, &mut export_clicked);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle export after rendering the filtered view
|
||||
if export_clicked {
|
||||
self.handle_export_filtered();
|
||||
}
|
||||
|
||||
// Handle keyboard input
|
||||
self.handle_keyboard_input(ctx);
|
||||
|
||||
// Render main view with improved styling
|
||||
render_main_log_panel(ctx, &highlight_rules, self.active_tab_mut());
|
||||
|
||||
// Only request repaint if there are ongoing background operations
|
||||
if self.indexing_state.is_indexing() || self.search_state.is_searching() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
463
src/main.rs
463
src/main.rs
@@ -10,19 +10,12 @@ mod tab_manager;
|
||||
mod theme;
|
||||
mod types;
|
||||
mod ui;
|
||||
mod log_viewer_app;
|
||||
|
||||
use eframe::egui;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::log_viewer_app::LogViewerApp;
|
||||
use config::AppConfig;
|
||||
use file_tab::FileTab;
|
||||
use highlight::HighlightManager;
|
||||
use search::{add_to_history, start_search, SearchParams, SearchState};
|
||||
use tab_manager::{close_tab, open_file_dialog, IndexingState};
|
||||
use ui::{
|
||||
render_highlight_editor, render_log_view, render_search_panel, render_tabs_panel,
|
||||
render_top_menu, LogViewContext, SearchPanelState,
|
||||
};
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
let options = eframe::NativeOptions {
|
||||
@@ -40,462 +33,10 @@ fn main() -> eframe::Result {
|
||||
"RLogg",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
// Apply the modern dark purple theme
|
||||
theme::apply_theme(&cc.egui_ctx);
|
||||
Ok(Box::new(LogViewerApp::new(config)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
struct LogViewerApp {
|
||||
tabs: Vec<FileTab>,
|
||||
active_tab_index: usize,
|
||||
search_panel_state: SearchPanelState,
|
||||
indexing_state: IndexingState,
|
||||
search_state: SearchState,
|
||||
highlight_manager: HighlightManager,
|
||||
first_frame: bool,
|
||||
}
|
||||
|
||||
impl LogViewerApp {
|
||||
fn new(config: AppConfig) -> Self {
|
||||
Self {
|
||||
tabs: Vec::new(),
|
||||
active_tab_index: 0,
|
||||
search_panel_state: SearchPanelState {
|
||||
query: config.last_search_query,
|
||||
case_sensitive: config.case_sensitive,
|
||||
use_regex: config.use_regex,
|
||||
history: config.search_history,
|
||||
date_range_enabled: config.date_range_enabled,
|
||||
date_format: config.date_format,
|
||||
date_from: config.date_from,
|
||||
date_to: config.date_to,
|
||||
},
|
||||
indexing_state: IndexingState::new(),
|
||||
search_state: SearchState::new(),
|
||||
highlight_manager: HighlightManager::new(config.highlight_rules),
|
||||
first_frame: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn active_tab(&self) -> Option<&FileTab> {
|
||||
self.tabs.get(self.active_tab_index)
|
||||
}
|
||||
|
||||
fn active_tab_mut(&mut self) -> Option<&mut FileTab> {
|
||||
self.tabs.get_mut(self.active_tab_index)
|
||||
}
|
||||
|
||||
fn save_config(&self) {
|
||||
let config = AppConfig {
|
||||
search_history: self.search_panel_state.history.clone(),
|
||||
case_sensitive: self.search_panel_state.case_sensitive,
|
||||
use_regex: self.search_panel_state.use_regex,
|
||||
last_search_query: self.search_panel_state.query.clone(),
|
||||
highlight_rules: self.highlight_manager.rules.clone(),
|
||||
date_range_enabled: self.search_panel_state.date_range_enabled,
|
||||
date_format: self.search_panel_state.date_format.clone(),
|
||||
date_from: self.search_panel_state.date_from.clone(),
|
||||
date_to: self.search_panel_state.date_to.clone(),
|
||||
};
|
||||
config.save();
|
||||
}
|
||||
|
||||
fn handle_open_file(&mut self) {
|
||||
if let Some(new_tab) = open_file_dialog(&self.indexing_state) {
|
||||
self.tabs.push(new_tab);
|
||||
self.active_tab_index = self.tabs.len() - 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_close_tab(&mut self, index: usize) {
|
||||
close_tab(&mut self.tabs, &mut self.active_tab_index, index);
|
||||
}
|
||||
|
||||
fn handle_search(&mut self) {
|
||||
if let Some(tab) = self.active_tab() {
|
||||
// Validate date format if date range is enabled
|
||||
if self.search_panel_state.date_range_enabled {
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
// Validate format by trying to parse example dates
|
||||
let test_date = "2025-01-01 00:00:00";
|
||||
if NaiveDateTime::parse_from_str(test_date, &self.search_panel_state.date_format).is_err() {
|
||||
eprintln!("Invalid date format: {}", self.search_panel_state.date_format);
|
||||
eprintln!("Expected format like: %Y-%m-%d %H:%M:%S");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate date_from and date_to can be parsed
|
||||
if !self.search_panel_state.date_from.is_empty() {
|
||||
if NaiveDateTime::parse_from_str(&self.search_panel_state.date_from, &self.search_panel_state.date_format).is_err() {
|
||||
eprintln!("Invalid date_from format. Expected format: {}", self.search_panel_state.date_format);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if !self.search_panel_state.date_to.is_empty() {
|
||||
if NaiveDateTime::parse_from_str(&self.search_panel_state.date_to, &self.search_panel_state.date_format).is_err() {
|
||||
eprintln!("Invalid date_to format. Expected format: {}", self.search_panel_state.date_format);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let params = SearchParams {
|
||||
query: self.search_panel_state.query.clone(),
|
||||
case_sensitive: self.search_panel_state.case_sensitive,
|
||||
use_regex: self.search_panel_state.use_regex,
|
||||
date_range_enabled: self.search_panel_state.date_range_enabled,
|
||||
date_format: self.search_panel_state.date_format.clone(),
|
||||
date_from: self.search_panel_state.date_from.clone(),
|
||||
date_to: self.search_panel_state.date_to.clone(),
|
||||
};
|
||||
|
||||
let line_index = Arc::clone(&tab.line_index);
|
||||
let file_path = tab.file_path.clone();
|
||||
|
||||
// Clear current results
|
||||
if let Some(tab) = self.active_tab_mut() {
|
||||
tab.filtered_lines.clear();
|
||||
}
|
||||
|
||||
start_search(&self.search_state, params, line_index, file_path);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_clear_search(&mut self) {
|
||||
self.search_panel_state.query.clear();
|
||||
if let Some(tab) = self.active_tab_mut() {
|
||||
tab.filtered_lines.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_export_filtered(&mut self) {
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::time::SystemTime;
|
||||
|
||||
if let Some(tab) = self.active_tab() {
|
||||
if tab.filtered_lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate timestamped filename
|
||||
let original_path = &tab.file_path;
|
||||
let file_stem = original_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("export");
|
||||
let extension = original_path
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("log");
|
||||
|
||||
// Generate timestamp
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
// Create new filename: filename_timestamp.extension
|
||||
let export_filename = format!("{}_{}.{}", file_stem, timestamp, extension);
|
||||
let export_path = original_path.with_file_name(export_filename);
|
||||
|
||||
// Write filtered lines to file
|
||||
match File::create(&export_path) {
|
||||
Ok(mut file) => {
|
||||
// Get the line index and file handle
|
||||
let line_index = Arc::clone(&tab.line_index);
|
||||
let file_path = tab.file_path.clone();
|
||||
|
||||
// Open the original file for reading
|
||||
if let Ok(original_file) = File::open(&file_path) {
|
||||
let mut reader = std::io::BufReader::new(original_file);
|
||||
|
||||
// Write each filtered line to the export file
|
||||
for filtered_line in &tab.filtered_lines {
|
||||
if let Some(content) = line_index.read_line(&mut reader, filtered_line.line_number) {
|
||||
writeln!(file, "{}", content).ok();
|
||||
}
|
||||
}
|
||||
|
||||
// Open the exported file in a new tab
|
||||
if let Ok(line_index) = crate::line_index::LineIndex::build(&export_path) {
|
||||
if let Ok(exported_file) = File::open(&export_path) {
|
||||
let new_tab = crate::file_tab::FileTab::new(
|
||||
export_path,
|
||||
line_index,
|
||||
exported_file,
|
||||
);
|
||||
self.tabs.push(new_tab);
|
||||
self.active_tab_index = self.tabs.len() - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to create export file: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_keyboard_input(&mut self, ctx: &egui::Context) {
|
||||
if let Some(tab) = self.active_tab_mut() {
|
||||
tab.page_scroll_direction = ctx.input(|i| {
|
||||
if i.key_pressed(egui::Key::PageDown) {
|
||||
Some(1.0)
|
||||
} else if i.key_pressed(egui::Key::PageUp) {
|
||||
Some(-1.0)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn update_search_results(&mut self) {
|
||||
if let Some(filtered) = self.search_state.take_results() {
|
||||
if let Some(tab) = self.active_tab_mut() {
|
||||
tab.filtered_lines = filtered;
|
||||
tab.filtered_scroll_offset = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for LogViewerApp {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
|
||||
if self.first_frame {
|
||||
self.first_frame = false;
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(true));
|
||||
}
|
||||
|
||||
// Update search results if available
|
||||
self.update_search_results();
|
||||
|
||||
// Render top menu
|
||||
let mut open_file_requested = false;
|
||||
render_top_menu(
|
||||
ctx,
|
||||
&mut self.highlight_manager,
|
||||
&self.indexing_state,
|
||||
&mut open_file_requested,
|
||||
);
|
||||
|
||||
if open_file_requested {
|
||||
self.handle_open_file();
|
||||
}
|
||||
|
||||
// Render highlight editor
|
||||
let highlight_config_changed = render_highlight_editor(ctx, &mut self.highlight_manager);
|
||||
if highlight_config_changed {
|
||||
self.save_config();
|
||||
}
|
||||
|
||||
// Render tabs
|
||||
let mut close_tab_index = None;
|
||||
render_tabs_panel(
|
||||
ctx,
|
||||
&self.tabs,
|
||||
&mut self.active_tab_index,
|
||||
&mut close_tab_index,
|
||||
);
|
||||
|
||||
if let Some(index) = close_tab_index {
|
||||
self.handle_close_tab(index);
|
||||
}
|
||||
|
||||
// Check for Ctrl+F keyboard shortcut
|
||||
let ctrl_f_pressed = ctx.input(|i| {
|
||||
i.modifiers.ctrl && i.key_pressed(egui::Key::F)
|
||||
});
|
||||
|
||||
// Check for Ctrl+Enter to execute search globally
|
||||
let ctrl_enter_pressed = ctx.input(|i| {
|
||||
i.modifiers.ctrl && i.key_pressed(egui::Key::Enter)
|
||||
});
|
||||
|
||||
// Render search panel
|
||||
let match_count = self.active_tab().map(|t| t.filtered_lines.len()).unwrap_or(0);
|
||||
let search_actions = render_search_panel(
|
||||
ctx,
|
||||
&mut self.search_panel_state,
|
||||
&self.search_state,
|
||||
match_count,
|
||||
ctrl_f_pressed,
|
||||
);
|
||||
|
||||
if search_actions.execute_search || ctrl_enter_pressed {
|
||||
add_to_history(
|
||||
&mut self.search_panel_state.history,
|
||||
&self.search_panel_state.query,
|
||||
);
|
||||
self.handle_search();
|
||||
}
|
||||
|
||||
if search_actions.clear_search {
|
||||
self.handle_clear_search();
|
||||
}
|
||||
|
||||
if search_actions.config_changed {
|
||||
self.save_config();
|
||||
}
|
||||
|
||||
// Render filtered view with improved styling
|
||||
// Show filtered view if there's a query OR if date range is enabled
|
||||
let show_filtered = !self.search_panel_state.query.is_empty() || self.search_panel_state.date_range_enabled;
|
||||
let highlight_rules = self.highlight_manager.rules.clone();
|
||||
|
||||
let mut export_clicked = false;
|
||||
if show_filtered {
|
||||
if let Some(tab) = self.active_tab_mut() {
|
||||
if !tab.filtered_lines.is_empty() {
|
||||
let palette = theme::get_palette();
|
||||
|
||||
egui::TopBottomPanel::bottom("filtered_view")
|
||||
.resizable(true)
|
||||
.default_height(250.0)
|
||||
.min_height(150.0)
|
||||
.show(ctx, |ui| {
|
||||
// Styled header
|
||||
let header_frame = egui::Frame::none()
|
||||
.fill(palette.bg_secondary)
|
||||
.inner_margin(egui::Margin::symmetric(12.0, 8.0));
|
||||
|
||||
header_frame.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let icon = egui::RichText::new("🔍")
|
||||
.size(16.0);
|
||||
ui.label(icon);
|
||||
|
||||
let title = egui::RichText::new("Search Results")
|
||||
.size(16.0)
|
||||
.color(palette.text_primary)
|
||||
.strong();
|
||||
ui.label(title);
|
||||
|
||||
let count = egui::RichText::new(format!("({} matches)", tab.filtered_lines.len()))
|
||||
.size(14.0)
|
||||
.color(palette.accent_bright);
|
||||
ui.label(count);
|
||||
|
||||
ui.add_space(16.0);
|
||||
|
||||
// Export button
|
||||
if ui.button("📤 Export").clicked() {
|
||||
export_clicked = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
render_log_view(
|
||||
ui,
|
||||
LogViewContext {
|
||||
tab,
|
||||
highlight_rules: &highlight_rules,
|
||||
show_all: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle export after rendering the filtered view
|
||||
if export_clicked {
|
||||
self.handle_export_filtered();
|
||||
}
|
||||
|
||||
// Handle keyboard input
|
||||
self.handle_keyboard_input(ctx);
|
||||
|
||||
// Render main view with improved styling
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
let palette = theme::get_palette();
|
||||
|
||||
if let Some(tab) = self.active_tab_mut() {
|
||||
// Styled header
|
||||
let header_frame = egui::Frame::none()
|
||||
.fill(palette.bg_secondary)
|
||||
.inner_margin(egui::Margin::symmetric(12.0, 8.0));
|
||||
|
||||
header_frame.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let icon = egui::RichText::new("📄")
|
||||
.size(16.0);
|
||||
ui.label(icon);
|
||||
|
||||
let title = egui::RichText::new("Log View")
|
||||
.size(16.0)
|
||||
.color(palette.text_primary)
|
||||
.strong();
|
||||
ui.label(title);
|
||||
|
||||
// Show filename
|
||||
if let Some(filename) = tab.file_path.file_name() {
|
||||
ui.add_space(8.0);
|
||||
let filename_text = egui::RichText::new(format!("• {}", filename.to_string_lossy()))
|
||||
.size(14.0)
|
||||
.color(palette.text_secondary);
|
||||
ui.label(filename_text);
|
||||
}
|
||||
|
||||
// Show line count
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let line_count = egui::RichText::new(format!("{} lines", tab.line_index.total_lines))
|
||||
.size(13.0)
|
||||
.color(palette.text_muted);
|
||||
ui.label(line_count);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
render_log_view(
|
||||
ui,
|
||||
LogViewContext {
|
||||
tab,
|
||||
highlight_rules: &highlight_rules,
|
||||
show_all: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ui.centered_and_justified(|ui| {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(40.0);
|
||||
|
||||
let icon = egui::RichText::new("📂")
|
||||
.size(48.0);
|
||||
ui.label(icon);
|
||||
|
||||
ui.add_space(16.0);
|
||||
|
||||
let text = egui::RichText::new("No file loaded")
|
||||
.size(18.0)
|
||||
.color(palette.text_secondary);
|
||||
ui.label(text);
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
let hint = egui::RichText::new("Click 'Open File' in the menu to get started")
|
||||
.size(14.0)
|
||||
.color(palette.text_muted);
|
||||
ui.label(hint);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Only request repaint if there are ongoing background operations
|
||||
if self.indexing_state.is_indexing() || self.search_state.is_searching() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
152
src/ui/log_panel.rs
Normal file
152
src/ui/log_panel.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
use eframe::egui;
|
||||
use crate::file_tab::FileTab;
|
||||
use crate::theme;
|
||||
use crate::theme::ColorPalette;
|
||||
use crate::types::HighlightRule;
|
||||
use crate::ui::{render_log_view, LogViewContext};
|
||||
|
||||
pub fn render_filter_panel(tab: &mut FileTab, ctx: &egui::Context, highlight_rules: &Vec<HighlightRule>, export_clicked: &mut bool){
|
||||
if !tab.filtered_lines.is_empty() {
|
||||
let palette = theme::get_palette();
|
||||
|
||||
egui::TopBottomPanel::bottom("filtered_view")
|
||||
.resizable(true)
|
||||
.default_height(250.0)
|
||||
.min_height(150.0)
|
||||
.show(ctx, |ui| {
|
||||
// Styled header
|
||||
render_filter_panel_header(ui, &palette, tab, export_clicked);
|
||||
|
||||
ui.separator();
|
||||
|
||||
render_log_view(
|
||||
ui,
|
||||
LogViewContext {
|
||||
tab,
|
||||
highlight_rules,
|
||||
show_all: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_main_log_panel(ctx: &egui::Context, highlight_rules: &Vec<HighlightRule>, active_tab: Option<&mut FileTab>) {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
let palette = theme::get_palette();
|
||||
|
||||
if let Some(tab) = active_tab {
|
||||
// Styled header
|
||||
|
||||
render_file_header(ui, palette, tab);
|
||||
|
||||
ui.separator();
|
||||
|
||||
render_log_view(
|
||||
ui,
|
||||
LogViewContext {
|
||||
tab,
|
||||
highlight_rules: &highlight_rules,
|
||||
show_all: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
render_no_file_opened_view(ui, palette);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn render_filter_panel_header(ui: &mut egui::Ui, palette: &ColorPalette, tab: &FileTab, export_clicked: &mut bool){
|
||||
let header_frame = egui::Frame::none()
|
||||
.fill(palette.bg_secondary)
|
||||
.inner_margin(egui::Margin::symmetric(12.0, 8.0));
|
||||
|
||||
header_frame.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let icon = egui::RichText::new("🔍")
|
||||
.size(16.0);
|
||||
ui.label(icon);
|
||||
|
||||
let title = egui::RichText::new("Search Results")
|
||||
.size(16.0)
|
||||
.color(palette.text_primary)
|
||||
.strong();
|
||||
ui.label(title);
|
||||
|
||||
let count = egui::RichText::new(format!("({} matches)", tab.filtered_lines.len()))
|
||||
.size(14.0)
|
||||
.color(palette.accent_bright);
|
||||
ui.label(count);
|
||||
|
||||
ui.add_space(16.0);
|
||||
|
||||
// Export button
|
||||
if ui.button("📤 Export").clicked() {
|
||||
*export_clicked = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn render_file_header(ui: &mut egui::Ui, palette: ColorPalette, tab: &FileTab) {
|
||||
let header_frame = egui::Frame::none()
|
||||
.fill(palette.bg_secondary)
|
||||
.inner_margin(egui::Margin::symmetric(12.0, 8.0));
|
||||
|
||||
header_frame.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let icon = egui::RichText::new("📄")
|
||||
.size(16.0);
|
||||
ui.label(icon);
|
||||
|
||||
let title = egui::RichText::new("Log View")
|
||||
.size(16.0)
|
||||
.color(palette.text_primary)
|
||||
.strong();
|
||||
ui.label(title);
|
||||
|
||||
// Show filename
|
||||
if let Some(filename) = tab.file_path.file_name() {
|
||||
ui.add_space(8.0);
|
||||
let filename_text = egui::RichText::new(format!("• {}", filename.to_string_lossy()))
|
||||
.size(14.0)
|
||||
.color(palette.text_secondary);
|
||||
ui.label(filename_text);
|
||||
}
|
||||
|
||||
// Show line count
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let line_count = egui::RichText::new(format!("{} lines", tab.line_index.total_lines))
|
||||
.size(13.0)
|
||||
.color(palette.text_muted);
|
||||
ui.label(line_count);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn render_no_file_opened_view(ui: &mut egui::Ui, palette: ColorPalette) {
|
||||
ui.centered_and_justified(|ui| {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(40.0);
|
||||
|
||||
let icon = egui::RichText::new("📂")
|
||||
.size(48.0);
|
||||
ui.label(icon);
|
||||
|
||||
ui.add_space(16.0);
|
||||
|
||||
let text = egui::RichText::new("No file loaded")
|
||||
.size(18.0)
|
||||
.color(palette.text_secondary);
|
||||
ui.label(text);
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
let hint = egui::RichText::new("Click 'Open File' in the menu to get started")
|
||||
.size(14.0)
|
||||
.color(palette.text_muted);
|
||||
ui.label(hint);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ pub mod log_view;
|
||||
pub mod search_panel;
|
||||
pub mod tabs_panel;
|
||||
pub mod top_menu;
|
||||
pub mod log_panel;
|
||||
|
||||
pub use highlight_editor::render_highlight_editor;
|
||||
pub use log_view::{render_log_view, LogViewContext};
|
||||
|
||||
@@ -3,16 +3,23 @@ use eframe::egui;
|
||||
use crate::highlight::HighlightManager;
|
||||
use crate::tab_manager::IndexingState;
|
||||
|
||||
pub struct TopMenuActions{
|
||||
pub open_file_requested: bool,
|
||||
}
|
||||
|
||||
pub fn render_top_menu(
|
||||
ctx: &egui::Context,
|
||||
highlight_manager: &mut HighlightManager,
|
||||
indexing_state: &IndexingState,
|
||||
on_open_file: &mut bool,
|
||||
) {
|
||||
|
||||
) -> TopMenuActions {
|
||||
|
||||
let mut top_menu_actions = TopMenuActions{open_file_requested: false};
|
||||
|
||||
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("📂 Open File").clicked() {
|
||||
*on_open_file = true;
|
||||
top_menu_actions.open_file_requested = true;
|
||||
}
|
||||
|
||||
if ui.button("🎨 Highlights").clicked() {
|
||||
@@ -46,4 +53,6 @@ pub fn render_top_menu(
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
top_menu_actions
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user