init, basic glogg clone
This commit is contained in:
39
src/config.rs
Normal file
39
src/config.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::types::HighlightRule;
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub struct AppConfig {
|
||||
pub search_history: Vec<String>,
|
||||
pub case_sensitive: bool,
|
||||
pub use_regex: bool,
|
||||
pub last_search_query: String,
|
||||
#[serde(default)]
|
||||
pub highlight_rules: Vec<HighlightRule>,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
fn config_path() -> PathBuf {
|
||||
let exe_path = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("."));
|
||||
let exe_dir = exe_path.parent().unwrap_or_else(|| std::path::Path::new("."));
|
||||
exe_dir.join("rlogg_config.json")
|
||||
}
|
||||
|
||||
pub fn load() -> Self {
|
||||
let path = Self::config_path();
|
||||
if let Ok(contents) = fs::read_to_string(&path) {
|
||||
serde_json::from_str(&contents).unwrap_or_default()
|
||||
} else {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) {
|
||||
let path = Self::config_path();
|
||||
if let Ok(json) = serde_json::to_string_pretty(self) {
|
||||
let _ = fs::write(&path, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
47
src/file_tab.rs
Normal file
47
src/file_tab.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::line_index::LineIndex;
|
||||
use crate::types::FilteredLine;
|
||||
|
||||
pub struct FileTab {
|
||||
pub file_path: PathBuf,
|
||||
pub line_index: Arc<LineIndex>,
|
||||
pub file_handle: BufReader<File>,
|
||||
pub filtered_lines: Vec<FilteredLine>,
|
||||
pub selected_line: Option<usize>,
|
||||
pub main_scroll_offset: usize,
|
||||
pub filtered_scroll_offset: usize,
|
||||
pub scroll_to_main: bool,
|
||||
pub page_scroll_direction: Option<f32>,
|
||||
pub desired_scroll_offset: f32,
|
||||
pub force_scroll: bool,
|
||||
}
|
||||
|
||||
impl FileTab {
|
||||
pub fn new(path: PathBuf, index: LineIndex, file: File) -> Self {
|
||||
Self {
|
||||
file_path: path,
|
||||
line_index: Arc::new(index),
|
||||
file_handle: BufReader::new(file),
|
||||
filtered_lines: Vec::new(),
|
||||
selected_line: None,
|
||||
main_scroll_offset: 0,
|
||||
filtered_scroll_offset: 0,
|
||||
scroll_to_main: false,
|
||||
page_scroll_direction: None,
|
||||
desired_scroll_offset: 0.0,
|
||||
force_scroll: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filename(&self) -> String {
|
||||
self.file_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
56
src/highlight.rs
Normal file
56
src/highlight.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use crate::types::HighlightRule;
|
||||
|
||||
pub struct HighlightManager {
|
||||
pub rules: Vec<HighlightRule>,
|
||||
pub show_editor: bool,
|
||||
pub new_pattern: String,
|
||||
pub new_color: [u8; 3],
|
||||
}
|
||||
|
||||
impl HighlightManager {
|
||||
pub fn new(rules: Vec<HighlightRule>) -> Self {
|
||||
Self {
|
||||
rules,
|
||||
show_editor: false,
|
||||
new_pattern: String::new(),
|
||||
new_color: [255, 255, 0],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_editor(&mut self) {
|
||||
self.show_editor = !self.show_editor;
|
||||
}
|
||||
|
||||
pub fn add_highlight(&mut self) -> bool {
|
||||
if self.new_pattern.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.rules.push(HighlightRule {
|
||||
pattern: self.new_pattern.clone(),
|
||||
color: self.new_color,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
self.new_pattern.clear();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn remove_highlight(&mut self, index: usize) -> bool {
|
||||
if index < self.rules.len() {
|
||||
self.rules.remove(index);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_rule(&mut self, index: usize) -> bool {
|
||||
if let Some(rule) = self.rules.get_mut(index) {
|
||||
rule.enabled = !rule.enabled;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/line_index.rs
Normal file
54
src/line_index.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct LineIndex {
|
||||
pub positions: Vec<u64>,
|
||||
pub total_lines: usize,
|
||||
}
|
||||
|
||||
impl LineIndex {
|
||||
pub fn build(file_path: &PathBuf) -> Result<Self, std::io::Error> {
|
||||
let file = File::open(file_path)?;
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut positions = vec![0u64];
|
||||
let mut line = String::new();
|
||||
let mut current_pos = 0u64;
|
||||
|
||||
loop {
|
||||
let bytes_read = reader.read_line(&mut line)?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
current_pos += bytes_read as u64;
|
||||
positions.push(current_pos);
|
||||
line.clear();
|
||||
}
|
||||
|
||||
let total_lines = positions.len().saturating_sub(1);
|
||||
positions.pop();
|
||||
|
||||
Ok(Self {
|
||||
positions,
|
||||
total_lines,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_line(&self, file: &mut BufReader<File>, line_num: usize) -> Option<String> {
|
||||
if line_num >= self.total_lines {
|
||||
return None;
|
||||
}
|
||||
|
||||
let pos = self.positions[line_num];
|
||||
if file.seek(SeekFrom::Start(pos)).is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut line = String::new();
|
||||
if file.read_line(&mut line).is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(line.trim_end_matches(['\r', '\n']).to_string())
|
||||
}
|
||||
}
|
||||
275
src/main.rs
Normal file
275
src/main.rs
Normal file
@@ -0,0 +1,275 @@
|
||||
mod config;
|
||||
mod file_tab;
|
||||
mod highlight;
|
||||
mod line_index;
|
||||
mod search;
|
||||
mod tab_manager;
|
||||
mod types;
|
||||
mod ui;
|
||||
|
||||
use eframe::egui;
|
||||
use std::sync::Arc;
|
||||
|
||||
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 {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size([1200.0, 800.0])
|
||||
.with_min_inner_size([800.0, 600.0])
|
||||
.with_maximized(true)
|
||||
.with_title("RLogg - Log Viewer"),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = AppConfig::load();
|
||||
|
||||
eframe::run_native(
|
||||
"RLogg",
|
||||
options,
|
||||
Box::new(move |_cc| 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,
|
||||
},
|
||||
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(),
|
||||
};
|
||||
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() {
|
||||
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,
|
||||
};
|
||||
|
||||
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_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);
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
|
||||
if search_actions.execute_search {
|
||||
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
|
||||
let show_filtered = !self.search_panel_state.query.is_empty();
|
||||
let highlight_rules = self.highlight_manager.rules.clone();
|
||||
|
||||
if show_filtered {
|
||||
if let Some(tab) = self.active_tab_mut() {
|
||||
if !tab.filtered_lines.is_empty() {
|
||||
egui::TopBottomPanel::bottom("filtered_view")
|
||||
.resizable(true)
|
||||
.default_height(200.0)
|
||||
.show(ctx, |ui| {
|
||||
ui.heading("Filtered View");
|
||||
ui.separator();
|
||||
|
||||
render_log_view(
|
||||
ui,
|
||||
LogViewContext {
|
||||
tab,
|
||||
highlight_rules: &highlight_rules,
|
||||
show_all: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle keyboard input
|
||||
self.handle_keyboard_input(ctx);
|
||||
|
||||
// Render main view
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.heading("Main Log View");
|
||||
ui.separator();
|
||||
|
||||
if let Some(tab) = self.active_tab_mut() {
|
||||
render_log_view(
|
||||
ui,
|
||||
LogViewContext {
|
||||
tab,
|
||||
highlight_rules: &highlight_rules,
|
||||
show_all: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ui.centered_and_justified(|ui| {
|
||||
ui.label("Click 'Open File' to load a log file");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Only request repaint if there are ongoing background operations
|
||||
if self.indexing_state.is_indexing() || self.search_state.is_searching() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
146
src/search.rs
Normal file
146
src/search.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
use regex::Regex;
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
|
||||
use crate::line_index::LineIndex;
|
||||
use crate::types::FilteredLine;
|
||||
|
||||
pub const MAX_SEARCH_HISTORY: usize = 50;
|
||||
|
||||
pub struct SearchState {
|
||||
pub searching: Arc<Mutex<bool>>,
|
||||
pub progress: Arc<Mutex<f32>>,
|
||||
pub results: Arc<Mutex<Option<Vec<FilteredLine>>>>,
|
||||
}
|
||||
|
||||
impl SearchState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
searching: Arc::new(Mutex::new(false)),
|
||||
progress: Arc::new(Mutex::new(0.0)),
|
||||
results: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_searching(&self) -> bool {
|
||||
*self.searching.lock().unwrap()
|
||||
}
|
||||
|
||||
pub fn get_progress(&self) -> f32 {
|
||||
*self.progress.lock().unwrap()
|
||||
}
|
||||
|
||||
pub fn take_results(&self) -> Option<Vec<FilteredLine>> {
|
||||
self.results.lock().unwrap().take()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SearchParams {
|
||||
pub query: String,
|
||||
pub case_sensitive: bool,
|
||||
pub use_regex: bool,
|
||||
}
|
||||
|
||||
impl SearchParams {
|
||||
fn build_regex_matcher(&self) -> Option<Regex> {
|
||||
if !self.use_regex {
|
||||
return None;
|
||||
}
|
||||
|
||||
let pattern = if self.case_sensitive {
|
||||
self.query.clone()
|
||||
} else {
|
||||
format!("(?i){}", self.query)
|
||||
};
|
||||
|
||||
Regex::new(&pattern).ok()
|
||||
}
|
||||
|
||||
fn matches_line(&self, content: &str, regex_matcher: &Option<Regex>) -> bool {
|
||||
if let Some(regex) = regex_matcher {
|
||||
regex.is_match(content)
|
||||
} else if self.use_regex {
|
||||
false
|
||||
} else if self.case_sensitive {
|
||||
content.contains(&self.query)
|
||||
} else {
|
||||
content.to_lowercase().contains(&self.query.to_lowercase())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_search(
|
||||
search_state: &SearchState,
|
||||
params: SearchParams,
|
||||
line_index: Arc<LineIndex>,
|
||||
file_path: std::path::PathBuf,
|
||||
) {
|
||||
if search_state.is_searching() {
|
||||
return;
|
||||
}
|
||||
|
||||
let searching = Arc::clone(&search_state.searching);
|
||||
let progress = Arc::clone(&search_state.progress);
|
||||
let results = Arc::clone(&search_state.results);
|
||||
|
||||
*searching.lock().unwrap() = true;
|
||||
*progress.lock().unwrap() = 0.0;
|
||||
*results.lock().unwrap() = None;
|
||||
|
||||
thread::spawn(move || {
|
||||
let filtered = search_lines(¶ms, &line_index, &file_path, &progress);
|
||||
*results.lock().unwrap() = Some(filtered);
|
||||
*progress.lock().unwrap() = 1.0;
|
||||
*searching.lock().unwrap() = false;
|
||||
});
|
||||
}
|
||||
|
||||
fn search_lines(
|
||||
params: &SearchParams,
|
||||
line_index: &LineIndex,
|
||||
file_path: &std::path::Path,
|
||||
progress: &Arc<Mutex<f32>>,
|
||||
) -> Vec<FilteredLine> {
|
||||
let mut filtered = Vec::new();
|
||||
let regex_matcher = params.build_regex_matcher();
|
||||
|
||||
if let Ok(file) = File::open(file_path) {
|
||||
let mut file_handle = BufReader::new(file);
|
||||
let total_lines = line_index.total_lines;
|
||||
|
||||
for line_num in 0..total_lines {
|
||||
if let Some(content) = line_index.read_line(&mut file_handle, line_num) {
|
||||
if params.matches_line(&content, ®ex_matcher) {
|
||||
filtered.push(FilteredLine {
|
||||
line_number: line_num,
|
||||
content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if line_num % 1000 == 0 {
|
||||
*progress.lock().unwrap() = line_num as f32 / total_lines as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filtered
|
||||
}
|
||||
|
||||
pub fn add_to_history(history: &mut Vec<String>, query: &str) {
|
||||
if query.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(pos) = history.iter().position(|x| x == query) {
|
||||
history.remove(pos);
|
||||
}
|
||||
|
||||
history.insert(0, query.to_string());
|
||||
|
||||
if history.len() > MAX_SEARCH_HISTORY {
|
||||
history.truncate(MAX_SEARCH_HISTORY);
|
||||
}
|
||||
}
|
||||
77
src/tab_manager.rs
Normal file
77
src/tab_manager.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use std::fs::File;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
|
||||
use crate::file_tab::FileTab;
|
||||
use crate::line_index::LineIndex;
|
||||
|
||||
pub struct IndexingState {
|
||||
pub indexing: Arc<Mutex<bool>>,
|
||||
pub progress: Arc<Mutex<f32>>,
|
||||
}
|
||||
|
||||
impl IndexingState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
indexing: Arc::new(Mutex::new(false)),
|
||||
progress: Arc::new(Mutex::new(0.0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_indexing(&self) -> bool {
|
||||
*self.indexing.lock().unwrap()
|
||||
}
|
||||
|
||||
fn start_indexing(&self) {
|
||||
*self.indexing.lock().unwrap() = true;
|
||||
*self.progress.lock().unwrap() = 0.0;
|
||||
}
|
||||
|
||||
fn finish_indexing(&self) {
|
||||
*self.indexing.lock().unwrap() = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_file_dialog(indexing_state: &IndexingState) -> Option<FileTab> {
|
||||
let path = rfd::FileDialog::new()
|
||||
.add_filter("Log Files", &["log", "txt"])
|
||||
.add_filter("All Files", &["*"])
|
||||
.pick_file()?;
|
||||
|
||||
open_file(path, indexing_state)
|
||||
}
|
||||
|
||||
fn open_file(path: PathBuf, indexing_state: &IndexingState) -> Option<FileTab> {
|
||||
indexing_state.start_indexing();
|
||||
|
||||
// Background indexing for progress indication
|
||||
let indexing = Arc::clone(&indexing_state.indexing);
|
||||
let progress = Arc::clone(&indexing_state.progress);
|
||||
let path_clone = path.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
if let Ok(_index) = LineIndex::build(&path_clone) {
|
||||
*progress.lock().unwrap() = 1.0;
|
||||
}
|
||||
*indexing.lock().unwrap() = false;
|
||||
});
|
||||
|
||||
// Build index and create tab
|
||||
let index = LineIndex::build(&path).ok()?;
|
||||
let file = File::open(&path).ok()?;
|
||||
let tab = FileTab::new(path, index, file);
|
||||
|
||||
indexing_state.finish_indexing();
|
||||
|
||||
Some(tab)
|
||||
}
|
||||
|
||||
pub fn close_tab(tabs: &mut Vec<FileTab>, active_index: &mut usize, index: usize) {
|
||||
if index < tabs.len() {
|
||||
tabs.remove(index);
|
||||
if *active_index >= tabs.len() && !tabs.is_empty() {
|
||||
*active_index = tabs.len() - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/types.rs
Normal file
14
src/types.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct HighlightRule {
|
||||
pub pattern: String,
|
||||
pub color: [u8; 3],
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FilteredLine {
|
||||
pub line_number: usize,
|
||||
pub content: String,
|
||||
}
|
||||
57
src/ui/highlight_editor.rs
Normal file
57
src/ui/highlight_editor.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use eframe::egui;
|
||||
|
||||
use crate::highlight::HighlightManager;
|
||||
|
||||
pub fn render_highlight_editor(ctx: &egui::Context, manager: &mut HighlightManager) -> bool {
|
||||
if !manager.show_editor {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut config_changed = false;
|
||||
|
||||
egui::Window::new("Highlight Manager")
|
||||
.collapsible(false)
|
||||
.show(ctx, |ui| {
|
||||
ui.heading("Add New Highlight");
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Pattern:");
|
||||
ui.text_edit_singleline(&mut manager.new_pattern);
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Color:");
|
||||
ui.color_edit_button_srgb(&mut manager.new_color);
|
||||
});
|
||||
|
||||
if ui.button("Add Highlight").clicked() && manager.add_highlight() {
|
||||
config_changed = true;
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
ui.heading("Existing Highlights");
|
||||
|
||||
let mut to_delete = None;
|
||||
for (idx, rule) in manager.rules.iter_mut().enumerate() {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.checkbox(&mut rule.enabled, "").changed() {
|
||||
config_changed = true;
|
||||
}
|
||||
ui.label(&rule.pattern);
|
||||
if ui.color_edit_button_srgb(&mut rule.color).changed() {
|
||||
config_changed = true;
|
||||
}
|
||||
|
||||
if ui.button("🗑").clicked() {
|
||||
to_delete = Some(idx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(idx) = to_delete {
|
||||
manager.remove_highlight(idx);
|
||||
config_changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
config_changed
|
||||
}
|
||||
241
src/ui/log_view.rs
Normal file
241
src/ui/log_view.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
use eframe::egui;
|
||||
|
||||
use crate::file_tab::FileTab;
|
||||
use crate::types::HighlightRule;
|
||||
|
||||
pub struct LogViewContext<'a> {
|
||||
pub tab: &'a mut FileTab,
|
||||
pub highlight_rules: &'a [HighlightRule],
|
||||
pub show_all: bool,
|
||||
}
|
||||
|
||||
pub fn render_log_view(ui: &mut egui::Ui, ctx: LogViewContext) {
|
||||
let LogViewContext {
|
||||
tab,
|
||||
highlight_rules,
|
||||
show_all,
|
||||
} = ctx;
|
||||
|
||||
let total_lines = if show_all {
|
||||
tab.line_index.total_lines
|
||||
} else {
|
||||
tab.filtered_lines.len()
|
||||
};
|
||||
|
||||
if total_lines == 0 {
|
||||
ui.centered_and_justified(|ui| {
|
||||
if show_all {
|
||||
ui.label("Click 'Open File' to load a log file");
|
||||
} else {
|
||||
ui.label("No matching lines");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let line_height = ui.text_style_height(&egui::TextStyle::Monospace);
|
||||
// Account for: frame inner margins (2.0 vertical) + horizontal layout spacing + frame outer spacing
|
||||
// The actual rendered height is approximately line_height + 6.0
|
||||
let row_height = line_height + 2.0;
|
||||
|
||||
let scroll_id_str = if show_all {
|
||||
"main_view_scroll"
|
||||
} else {
|
||||
"filtered_view_scroll"
|
||||
};
|
||||
let scroll_id = egui::Id::new(scroll_id_str);
|
||||
|
||||
// Only handle scroll-to-line and page-scroll for the main view
|
||||
if show_all {
|
||||
handle_scroll_to_line(ui, &scroll_id, tab, row_height);
|
||||
handle_page_scroll(ui, &scroll_id, tab, row_height, total_lines);
|
||||
}
|
||||
|
||||
let mut scroll_area = egui::ScrollArea::both()
|
||||
.auto_shrink([false; 2])
|
||||
.id_salt(scroll_id);
|
||||
|
||||
if show_all && tab.force_scroll {
|
||||
scroll_area = scroll_area.vertical_scroll_offset(tab.desired_scroll_offset);
|
||||
tab.force_scroll = false;
|
||||
}
|
||||
|
||||
let scroll_output = scroll_area.show_rows(ui, row_height, total_lines, |ui, row_range| {
|
||||
render_visible_lines(ui, tab, highlight_rules, show_all, row_range);
|
||||
});
|
||||
|
||||
// Update the scroll offset from the actual scroll state
|
||||
if show_all {
|
||||
let actual_offset = scroll_output.state.offset.y;
|
||||
// eprintln!("Actual scroll offset after render: {}", actual_offset);
|
||||
if (actual_offset - tab.desired_scroll_offset).abs() > 1.0 {
|
||||
eprintln!(
|
||||
"SYNC: Updating desired_scroll_offset from {} to {}",
|
||||
tab.desired_scroll_offset, actual_offset
|
||||
);
|
||||
}
|
||||
tab.desired_scroll_offset = actual_offset;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_scroll_to_line(
|
||||
_ui: &mut egui::Ui,
|
||||
_scroll_id: &egui::Id,
|
||||
tab: &mut FileTab,
|
||||
row_height: f32,
|
||||
) {
|
||||
if tab.scroll_to_main {
|
||||
let target_row = tab.main_scroll_offset;
|
||||
let adgusted_row_height = row_height + 3f32;
|
||||
let scroll_offset = (target_row as f32) * adgusted_row_height;
|
||||
|
||||
eprintln!("=== SCROLL TO LINE ===");
|
||||
eprintln!(" main_scroll_offset (selected line): {}", tab.main_scroll_offset);
|
||||
eprintln!(" target_row (with -5 context): {}", target_row);
|
||||
eprintln!(" row_height: {}", row_height);
|
||||
eprintln!(" calculated scroll_offset: {}", scroll_offset);
|
||||
eprintln!(" force_scroll: true");
|
||||
|
||||
tab.desired_scroll_offset = scroll_offset;
|
||||
tab.force_scroll = true;
|
||||
tab.scroll_to_main = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_page_scroll(
|
||||
ui: &mut egui::Ui,
|
||||
_scroll_id: &egui::Id,
|
||||
tab: &mut FileTab,
|
||||
row_height: f32,
|
||||
total_lines: usize,
|
||||
) {
|
||||
if let Some(direction) = tab.page_scroll_direction.take() {
|
||||
let viewport_height = ui.available_height();
|
||||
let rows_per_page = (viewport_height / row_height).floor().max(1.0);
|
||||
let scroll_delta = direction * rows_per_page * row_height;
|
||||
|
||||
let max_offset = (total_lines as f32 * row_height - viewport_height).max(0.0);
|
||||
let new_offset = (tab.desired_scroll_offset + scroll_delta).clamp(0.0, max_offset);
|
||||
|
||||
eprintln!(
|
||||
"Page scroll: current_offset={}, scroll_delta={}, new_offset={}",
|
||||
tab.desired_scroll_offset, scroll_delta, new_offset
|
||||
);
|
||||
tab.desired_scroll_offset = new_offset;
|
||||
tab.force_scroll = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn render_visible_lines(
|
||||
ui: &mut egui::Ui,
|
||||
tab: &mut FileTab,
|
||||
highlight_rules: &[HighlightRule],
|
||||
show_all: bool,
|
||||
row_range: std::ops::Range<usize>,
|
||||
) {
|
||||
ui.style_mut().spacing.item_spacing.y = 0.0;
|
||||
|
||||
for display_idx in row_range {
|
||||
let (line_num, content) = get_line_content(tab, show_all, display_idx);
|
||||
let is_selected = tab.selected_line == Some(line_num);
|
||||
|
||||
render_line(
|
||||
ui,
|
||||
&content,
|
||||
line_num,
|
||||
is_selected,
|
||||
highlight_rules,
|
||||
|clicked| {
|
||||
if clicked {
|
||||
tab.selected_line = Some(line_num);
|
||||
if !show_all {
|
||||
tab.main_scroll_offset = line_num;
|
||||
tab.scroll_to_main = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_line_content(tab: &mut FileTab, show_all: bool, display_idx: usize) -> (usize, String) {
|
||||
if show_all {
|
||||
let content = tab
|
||||
.line_index
|
||||
.read_line(&mut tab.file_handle, display_idx)
|
||||
.unwrap_or_default();
|
||||
(display_idx, content)
|
||||
} else {
|
||||
if display_idx < tab.filtered_lines.len() {
|
||||
let filtered = &tab.filtered_lines[display_idx];
|
||||
(filtered.line_number, filtered.content.clone())
|
||||
} else {
|
||||
(0, String::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_line<F>(
|
||||
ui: &mut egui::Ui,
|
||||
content: &str,
|
||||
line_num: usize,
|
||||
is_selected: bool,
|
||||
highlight_rules: &[HighlightRule],
|
||||
on_click: F,
|
||||
) where
|
||||
F: FnOnce(bool),
|
||||
{
|
||||
let highlight_color = highlight_rules
|
||||
.iter()
|
||||
.find(|rule| rule.enabled && content.contains(&rule.pattern))
|
||||
.map(|rule| egui::Color32::from_rgb(rule.color[0], rule.color[1], rule.color[2]));
|
||||
|
||||
let bg_color = if is_selected {
|
||||
egui::Color32::from_rgb(70, 130, 180)
|
||||
} else if let Some(color) = highlight_color {
|
||||
color
|
||||
} else {
|
||||
egui::Color32::TRANSPARENT
|
||||
};
|
||||
|
||||
let frame = egui::Frame::none()
|
||||
.fill(bg_color)
|
||||
.inner_margin(egui::Margin::symmetric(2.0, 1.0));
|
||||
|
||||
frame.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let line_num_text = egui::RichText::new(format!("{:6} ", line_num + 1))
|
||||
.monospace()
|
||||
.color(if is_selected {
|
||||
egui::Color32::WHITE
|
||||
} else {
|
||||
egui::Color32::DARK_GRAY
|
||||
});
|
||||
|
||||
let line_num_response = ui.label(line_num_text);
|
||||
|
||||
let text = egui::RichText::new(content).monospace().color(
|
||||
if is_selected {
|
||||
egui::Color32::WHITE
|
||||
} else {
|
||||
ui.style().visuals.text_color()
|
||||
},
|
||||
);
|
||||
|
||||
let text_response = ui
|
||||
.scope(|ui| {
|
||||
ui.style_mut().visuals.selection.bg_fill =
|
||||
egui::Color32::from_rgb(255, 180, 50);
|
||||
ui.style_mut().visuals.selection.stroke.color =
|
||||
egui::Color32::from_rgb(200, 140, 30);
|
||||
ui.add(egui::Label::new(text).selectable(true))
|
||||
})
|
||||
.inner;
|
||||
|
||||
let clicked =
|
||||
line_num_response.clicked() || (text_response.clicked() && !text_response.has_focus());
|
||||
on_click(clicked);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
11
src/ui/mod.rs
Normal file
11
src/ui/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
pub mod highlight_editor;
|
||||
pub mod log_view;
|
||||
pub mod search_panel;
|
||||
pub mod tabs_panel;
|
||||
pub mod top_menu;
|
||||
|
||||
pub use highlight_editor::render_highlight_editor;
|
||||
pub use log_view::{render_log_view, LogViewContext};
|
||||
pub use search_panel::{render_search_panel, SearchPanelState};
|
||||
pub use tabs_panel::render_tabs_panel;
|
||||
pub use top_menu::render_top_menu;
|
||||
99
src/ui/search_panel.rs
Normal file
99
src/ui/search_panel.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use eframe::egui;
|
||||
|
||||
use crate::search::SearchState;
|
||||
|
||||
pub struct SearchPanelState {
|
||||
pub query: String,
|
||||
pub case_sensitive: bool,
|
||||
pub use_regex: bool,
|
||||
pub history: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct SearchPanelActions {
|
||||
pub execute_search: bool,
|
||||
pub clear_search: bool,
|
||||
pub config_changed: bool,
|
||||
}
|
||||
|
||||
pub fn render_search_panel(
|
||||
ctx: &egui::Context,
|
||||
state: &mut SearchPanelState,
|
||||
search_state: &SearchState,
|
||||
match_count: usize,
|
||||
) -> SearchPanelActions {
|
||||
let mut actions = SearchPanelActions {
|
||||
execute_search: false,
|
||||
clear_search: false,
|
||||
config_changed: false,
|
||||
};
|
||||
|
||||
egui::TopBottomPanel::bottom("search_panel").show(ctx, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("🔍 Filter:");
|
||||
|
||||
let text_edit_width = 200.0;
|
||||
let text_response = ui.add_sized(
|
||||
[text_edit_width, 20.0],
|
||||
egui::TextEdit::singleline(&mut state.query),
|
||||
);
|
||||
|
||||
let enter_pressed =
|
||||
text_response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
|
||||
if !state.history.is_empty() {
|
||||
render_history_dropdown(ui, state);
|
||||
}
|
||||
|
||||
let case_changed = ui
|
||||
.checkbox(&mut state.case_sensitive, "Case sensitive")
|
||||
.changed();
|
||||
let regex_changed = ui.checkbox(&mut state.use_regex, "Regex").changed();
|
||||
|
||||
if case_changed || regex_changed {
|
||||
actions.config_changed = true;
|
||||
}
|
||||
|
||||
if ui.button("Search").clicked() || enter_pressed {
|
||||
actions.execute_search = true;
|
||||
actions.config_changed = true;
|
||||
}
|
||||
|
||||
if !state.query.is_empty() {
|
||||
if ui.button("✖ Clear").clicked() {
|
||||
actions.clear_search = true;
|
||||
}
|
||||
|
||||
ui.label(format!("({} matches)", match_count));
|
||||
}
|
||||
});
|
||||
|
||||
if search_state.is_searching() {
|
||||
let progress = search_state.get_progress();
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::ProgressBar::new(progress)
|
||||
.text(format!("Searching... {:.0}%", progress * 100.0))
|
||||
.animate(true),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
actions
|
||||
}
|
||||
|
||||
fn render_history_dropdown(ui: &mut egui::Ui, state: &mut SearchPanelState) {
|
||||
egui::ComboBox::from_id_salt("search_history_dropdown")
|
||||
.selected_text("▼")
|
||||
.width(30.0)
|
||||
.show_ui(ui, |ui| {
|
||||
ui.set_min_width(300.0);
|
||||
for history_item in &state.history.clone() {
|
||||
if ui.selectable_label(false, history_item).clicked() {
|
||||
state.query = history_item.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
41
src/ui/tabs_panel.rs
Normal file
41
src/ui/tabs_panel.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use eframe::egui;
|
||||
|
||||
use crate::file_tab::FileTab;
|
||||
|
||||
pub fn render_tabs_panel(
|
||||
ctx: &egui::Context,
|
||||
tabs: &[FileTab],
|
||||
active_tab_index: &mut usize,
|
||||
on_close_tab: &mut Option<usize>,
|
||||
) {
|
||||
if tabs.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
egui::TopBottomPanel::top("tabs_panel").show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for (idx, tab) in tabs.iter().enumerate() {
|
||||
let is_active = idx == *active_tab_index;
|
||||
let button_text = if is_active {
|
||||
egui::RichText::new(format!("📄 {}", tab.filename())).strong()
|
||||
} else {
|
||||
egui::RichText::new(format!("📄 {}", tab.filename()))
|
||||
};
|
||||
|
||||
if ui.selectable_label(is_active, button_text).clicked() {
|
||||
*active_tab_index = idx;
|
||||
}
|
||||
|
||||
if ui.small_button("✖").clicked() {
|
||||
*on_close_tab = Some(idx);
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
}
|
||||
|
||||
if let Some(tab) = tabs.get(*active_tab_index) {
|
||||
ui.label(format!("({} lines)", tab.line_index.total_lines));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
49
src/ui/top_menu.rs
Normal file
49
src/ui/top_menu.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use eframe::egui;
|
||||
|
||||
use crate::highlight::HighlightManager;
|
||||
use crate::tab_manager::IndexingState;
|
||||
|
||||
pub fn render_top_menu(
|
||||
ctx: &egui::Context,
|
||||
highlight_manager: &mut HighlightManager,
|
||||
indexing_state: &IndexingState,
|
||||
on_open_file: &mut bool,
|
||||
) {
|
||||
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("📂 Open File").clicked() {
|
||||
*on_open_file = true;
|
||||
}
|
||||
|
||||
if ui.button("🎨 Highlights").clicked() {
|
||||
highlight_manager.toggle_editor();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
let mut to_toggle = None;
|
||||
for (idx, rule) in highlight_manager.rules.iter().enumerate() {
|
||||
let color = egui::Color32::from_rgb(rule.color[0], rule.color[1], rule.color[2]);
|
||||
let button_text = egui::RichText::new(&rule.pattern)
|
||||
.background_color(color)
|
||||
.color(egui::Color32::BLACK);
|
||||
|
||||
if ui.selectable_label(rule.enabled, button_text).clicked() {
|
||||
to_toggle = Some(idx);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(idx) = to_toggle {
|
||||
highlight_manager.toggle_rule(idx);
|
||||
// Signal that config should be saved
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if indexing_state.is_indexing() {
|
||||
ui.spinner();
|
||||
ui.label("Indexing...");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user