Compare commits
15 Commits
fb9967ea41
...
13d72a5c11
| Author | SHA1 | Date | |
|---|---|---|---|
| 13d72a5c11 | |||
| d8255bfc02 | |||
| 5ddbc1b8e1 | |||
| 6538ff683a | |||
| a439b1fe32 | |||
| ede0b6983d | |||
| e23323d0b4 | |||
| 225c5bdb91 | |||
| a9df93fa5a | |||
| e60c20aefa | |||
| 69bf009d58 | |||
| dac0b12fca | |||
| aa39fc07b3 | |||
| da1f40e54f | |||
| e60f2bf319 |
5
.cargo/config.toml
Normal file
5
.cargo/config.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Cargo configuration for cross-compilation
|
||||||
|
|
||||||
|
[target.x86_64-pc-windows-gnu]
|
||||||
|
linker = "x86_64-w64-mingw32-gcc"
|
||||||
|
ar = "x86_64-w64-mingw32-ar"
|
||||||
108
.github/workflows/build.yml
vendored
Normal file
108
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
name: Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master, main ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ master, main ]
|
||||||
|
release:
|
||||||
|
types: [ created ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build - ${{ matrix.platform.name }}
|
||||||
|
runs-on: ${{ matrix.platform.os }}
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
platform:
|
||||||
|
- name: Linux-x86_64
|
||||||
|
os: ubuntu-latest
|
||||||
|
target: x86_64-unknown-linux-gnu
|
||||||
|
bin: rlogg
|
||||||
|
command: build
|
||||||
|
|
||||||
|
- name: Windows-x86_64
|
||||||
|
os: windows-latest
|
||||||
|
target: x86_64-pc-windows-msvc
|
||||||
|
bin: rlogg.exe
|
||||||
|
command: build
|
||||||
|
|
||||||
|
- name: macOS-x86_64
|
||||||
|
os: macos-latest
|
||||||
|
target: x86_64-apple-darwin
|
||||||
|
bin: rlogg
|
||||||
|
command: build
|
||||||
|
|
||||||
|
- name: macOS-aarch64
|
||||||
|
os: macos-latest
|
||||||
|
target: aarch64-apple-darwin
|
||||||
|
bin: rlogg
|
||||||
|
command: build
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ matrix.platform.target }}
|
||||||
|
|
||||||
|
- name: Cache cargo registry
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cargo/registry
|
||||||
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Cache cargo index
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cargo/git
|
||||||
|
key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Cache cargo build
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Install Linux dependencies
|
||||||
|
if: matrix.platform.os == 'ubuntu-latest'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgtk-3-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: cargo ${{ matrix.platform.command }} --release --target ${{ matrix.platform.target }}
|
||||||
|
|
||||||
|
- name: Strip binary (Linux and macOS)
|
||||||
|
if: matrix.platform.os != 'windows-latest'
|
||||||
|
run: strip target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}
|
||||||
|
|
||||||
|
- name: Create artifact directory
|
||||||
|
run: mkdir -p artifacts
|
||||||
|
|
||||||
|
- name: Copy binary to artifacts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cp target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }} artifacts/
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: rlogg-${{ matrix.platform.name }}
|
||||||
|
path: artifacts/${{ matrix.platform.bin }}
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload to release
|
||||||
|
if: github.event_name == 'release'
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
files: artifacts/${{ matrix.platform.bin }}
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Build artifacts
|
||||||
|
/target
|
||||||
|
/dist
|
||||||
|
|
||||||
|
# Cargo lock file (optional for binaries)
|
||||||
|
# Cargo.lock
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
210
BUILD.md
Normal file
210
BUILD.md
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# Building RLogg
|
||||||
|
|
||||||
|
This document describes how to build RLogg for different platforms.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
**On Linux, you can build for all platforms including Windows:**
|
||||||
|
```bash
|
||||||
|
./cross-compile.sh all
|
||||||
|
```
|
||||||
|
|
||||||
|
This uses Docker-based cross-compilation, so no Windows tools needed!
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Rust toolchain (install from https://rustup.rs/)
|
||||||
|
- Platform-specific dependencies (see below)
|
||||||
|
- For cross-compilation: Docker (for the `cross` tool)
|
||||||
|
|
||||||
|
## Platform-Specific Dependencies
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
```bash
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgtk-3-dev libxcb-render0-dev libxcb-shape0-dev \
|
||||||
|
libxcb-xfixes0-dev libxkbcommon-dev libssl-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
No additional dependencies required. Xcode Command Line Tools should be installed.
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
No additional dependencies required. Visual Studio Build Tools or Visual Studio with C++ development tools should be installed.
|
||||||
|
|
||||||
|
## Quick Build
|
||||||
|
|
||||||
|
### Development Build
|
||||||
|
```bash
|
||||||
|
cargo build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Release Build (Optimized)
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
The binary will be in `target/release/rlogg` (or `rlogg.exe` on Windows).
|
||||||
|
|
||||||
|
## Multi-Platform Builds
|
||||||
|
|
||||||
|
### Using Build Scripts
|
||||||
|
|
||||||
|
#### Linux/macOS
|
||||||
|
```bash
|
||||||
|
./build-release.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
```powershell
|
||||||
|
.\build-release.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
The built binaries will be in the `dist/` directory.
|
||||||
|
|
||||||
|
### Manual Cross-Compilation
|
||||||
|
|
||||||
|
#### Install Target
|
||||||
|
```bash
|
||||||
|
# For Linux
|
||||||
|
rustup target add x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
|
# For Windows
|
||||||
|
rustup target add x86_64-pc-windows-msvc
|
||||||
|
|
||||||
|
# For macOS x86_64
|
||||||
|
rustup target add x86_64-apple-darwin
|
||||||
|
|
||||||
|
# For macOS ARM (M1/M2)
|
||||||
|
rustup target add aarch64-apple-darwin
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Build for Specific Target
|
||||||
|
```bash
|
||||||
|
cargo build --release --target <target-triple>
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
```bash
|
||||||
|
# Linux
|
||||||
|
cargo build --release --target x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
cargo build --release --target x86_64-pc-windows-msvc
|
||||||
|
|
||||||
|
# macOS Intel
|
||||||
|
cargo build --release --target x86_64-apple-darwin
|
||||||
|
|
||||||
|
# macOS Apple Silicon
|
||||||
|
cargo build --release --target aarch64-apple-darwin
|
||||||
|
```
|
||||||
|
|
||||||
|
## GitHub Actions (Automated Builds)
|
||||||
|
|
||||||
|
The project includes GitHub Actions workflow that automatically builds for all platforms:
|
||||||
|
- Linux x86_64
|
||||||
|
- Windows x86_64
|
||||||
|
- macOS x86_64 (Intel)
|
||||||
|
- macOS aarch64 (Apple Silicon)
|
||||||
|
|
||||||
|
### Triggering Builds
|
||||||
|
|
||||||
|
- **Push to master/main**: Builds all platforms and uploads artifacts
|
||||||
|
- **Pull Request**: Runs build checks
|
||||||
|
- **Release**: Builds and attaches binaries to the GitHub release
|
||||||
|
|
||||||
|
### Creating a Release
|
||||||
|
|
||||||
|
1. Tag your commit:
|
||||||
|
```bash
|
||||||
|
git tag -a v0.1.0 -m "Release v0.1.0"
|
||||||
|
git push origin v0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create a release on GitHub from the tag
|
||||||
|
|
||||||
|
3. GitHub Actions will automatically build and attach binaries
|
||||||
|
|
||||||
|
## Optimizing Binary Size
|
||||||
|
|
||||||
|
To reduce the binary size, you can use these settings in `Cargo.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "z" # Optimize for size
|
||||||
|
lto = true # Enable Link Time Optimization
|
||||||
|
codegen-units = 1 # Better optimization
|
||||||
|
strip = true # Strip symbols
|
||||||
|
```
|
||||||
|
|
||||||
|
Or build with:
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
strip target/release/rlogg # On Linux/macOS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Linux: Missing libraries
|
||||||
|
Make sure all required development libraries are installed:
|
||||||
|
```bash
|
||||||
|
sudo apt-get install -y libgtk-3-dev libxcb-render0-dev libxcb-shape0-dev \
|
||||||
|
libxcb-xfixes0-dev libxkbcommon-dev libssl-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS: Code signing issues
|
||||||
|
If you encounter code signing errors, you can sign the binary:
|
||||||
|
```bash
|
||||||
|
codesign --force --deep --sign - target/release/rlogg
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows: MSVC not found
|
||||||
|
Install Visual Studio Build Tools with C++ development tools, or install full Visual Studio.
|
||||||
|
|
||||||
|
### Cross-compilation from Linux to Windows
|
||||||
|
|
||||||
|
#### Method 1: Using the cross-compile script (Recommended)
|
||||||
|
```bash
|
||||||
|
# Interactive mode
|
||||||
|
./cross-compile.sh
|
||||||
|
|
||||||
|
# Or specify target directly
|
||||||
|
./cross-compile.sh windows # Windows (MinGW)
|
||||||
|
./cross-compile.sh all # All platforms
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Only MinGW/GNU target is supported for cross-compilation from Linux. MSVC requires Windows.
|
||||||
|
|
||||||
|
#### Method 2: Manual with 'cross'
|
||||||
|
Install `cross` and build:
|
||||||
|
```bash
|
||||||
|
cargo install cross --git https://github.com/cross-rs/cross
|
||||||
|
cross build --release --target x86_64-pc-windows-msvc
|
||||||
|
```
|
||||||
|
|
||||||
|
The `cross` tool uses Docker to provide a complete cross-compilation environment, so you don't need to install Windows-specific tools.
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running in Development Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checking Code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check for errors without building
|
||||||
|
cargo check
|
||||||
|
|
||||||
|
# Run linter
|
||||||
|
cargo clippy
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
cargo fmt
|
||||||
|
```
|
||||||
160
CROSS_COMPILE.md
Normal file
160
CROSS_COMPILE.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# Cross-Compilation Guide
|
||||||
|
|
||||||
|
Build Windows binaries from Linux using Docker-based cross-compilation.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Install Docker**:
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt-get install docker.io
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
# Log out and back in for group changes to take effect
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Verify Docker**:
|
||||||
|
```bash
|
||||||
|
docker --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Option 1: Interactive Menu
|
||||||
|
```bash
|
||||||
|
./cross-compile.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Then select:
|
||||||
|
- `1` - Linux native
|
||||||
|
- `2` - Windows (MinGW)
|
||||||
|
- `3` - Both platforms
|
||||||
|
- `4` - Exit
|
||||||
|
|
||||||
|
### Option 2: Command Line
|
||||||
|
```bash
|
||||||
|
./cross-compile.sh windows # Windows (MinGW/GNU)
|
||||||
|
./cross-compile.sh all # All platforms
|
||||||
|
./cross-compile.sh linux # Linux native
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Make
|
||||||
|
```bash
|
||||||
|
make cross-windows # Build for Windows
|
||||||
|
make cross-all # Build for all platforms
|
||||||
|
```
|
||||||
|
|
||||||
|
## What Gets Built
|
||||||
|
|
||||||
|
Binaries are created in the `dist/` directory:
|
||||||
|
- `rlogg-linux-x86_64` - Linux binary
|
||||||
|
- `rlogg-windows-x86_64.exe` - Windows binary (MinGW)
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
The `cross` tool:
|
||||||
|
1. Automatically installs on first use
|
||||||
|
2. Uses Docker to run cross-compilation in containers
|
||||||
|
3. Provides complete toolchains for each target
|
||||||
|
4. No need to install Windows SDK or MinGW manually
|
||||||
|
|
||||||
|
## About Windows Cross-Compilation
|
||||||
|
|
||||||
|
### Why MinGW/GNU Only?
|
||||||
|
|
||||||
|
When cross-compiling from Linux to Windows, only the **GNU target** (`x86_64-pc-windows-gnu`) is supported:
|
||||||
|
|
||||||
|
- **MinGW (GNU)** - Uses open-source MinGW toolchain
|
||||||
|
- ✅ Fully supported by `cross` from Linux
|
||||||
|
- ✅ Works great for most Windows applications
|
||||||
|
- ✅ Smaller binaries
|
||||||
|
- ✅ No runtime dependencies on Visual C++ redistributables
|
||||||
|
|
||||||
|
- **MSVC** - Microsoft Visual C++ toolchain
|
||||||
|
- ❌ Not available for cross-compilation from Linux
|
||||||
|
- ❌ Requires proprietary Microsoft tools
|
||||||
|
- ❌ Can only be built on Windows or via Windows VM
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
The MinGW binaries work on **all Windows systems** (Windows 7+) without requiring additional runtime installations. They're fully compatible with standard Windows applications.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Docker permission denied
|
||||||
|
```bash
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
# Log out and back in
|
||||||
|
```
|
||||||
|
|
||||||
|
### cross installation fails
|
||||||
|
```bash
|
||||||
|
# Install from source
|
||||||
|
cargo install cross --git https://github.com/cross-rs/cross
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build fails with linker errors
|
||||||
|
The `cross` tool handles all linker configuration automatically. If you see linker errors, try:
|
||||||
|
```bash
|
||||||
|
# Clean and rebuild
|
||||||
|
make clean
|
||||||
|
./cross-compile.sh windows
|
||||||
|
```
|
||||||
|
|
||||||
|
### Very slow first build
|
||||||
|
The first build downloads the Docker image (~1-2 GB) and compiles dependencies. Subsequent builds are much faster due to caching.
|
||||||
|
|
||||||
|
## Alternative: Manual Cross-Compilation (Advanced)
|
||||||
|
|
||||||
|
If you don't want to use Docker, you can manually set up cross-compilation:
|
||||||
|
|
||||||
|
### For Windows from Linux
|
||||||
|
```bash
|
||||||
|
# Install MinGW
|
||||||
|
sudo apt-get install mingw-w64
|
||||||
|
|
||||||
|
# Add Rust target
|
||||||
|
rustup target add x86_64-pc-windows-gnu
|
||||||
|
|
||||||
|
# Configure cargo
|
||||||
|
mkdir -p ~/.cargo
|
||||||
|
cat >> ~/.cargo/config.toml << EOF
|
||||||
|
[target.x86_64-pc-windows-gnu]
|
||||||
|
linker = "x86_64-w64-mingw32-gcc"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Build
|
||||||
|
cargo build --release --target x86_64-pc-windows-gnu
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: This only works for the GNU target, not MSVC.
|
||||||
|
|
||||||
|
## Testing Windows Binaries on Linux
|
||||||
|
|
||||||
|
Use Wine to test Windows binaries:
|
||||||
|
```bash
|
||||||
|
# Install Wine
|
||||||
|
sudo apt-get install wine64
|
||||||
|
|
||||||
|
# Run the Windows binary
|
||||||
|
wine dist/rlogg-windows-x86_64-gnu.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
|
||||||
|
The GitHub Actions workflow automatically cross-compiles for all platforms. Just push to trigger builds:
|
||||||
|
```bash
|
||||||
|
git push origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
Artifacts are available in the Actions tab.
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
Cross-compilation is:
|
||||||
|
- **Fast**: Similar speed to native compilation
|
||||||
|
- **Cached**: Dependencies are cached in Docker volumes
|
||||||
|
- **Isolated**: Doesn't affect your system
|
||||||
|
|
||||||
|
Typical build times:
|
||||||
|
- First build: 2-5 minutes (includes Docker image download)
|
||||||
|
- Incremental builds: 30-60 seconds
|
||||||
4490
Cargo.lock
generated
Normal file
4490
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
Cargo.toml
Normal file
20
Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "rlogg"
|
||||||
|
version = "0.3.0"
|
||||||
|
edition = "2024"
|
||||||
|
authors = ["Stanislav Pastushenko <staspast1@gmail.com>"]
|
||||||
|
description = "A fast log file viewer with search, filtering, and highlighting capabilities"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = "https://github.com/yourusername/rlogg"
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = ["log", "viewer", "gui", "search", "filter"]
|
||||||
|
categories = ["command-line-utilities", "gui"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
eframe = "0.29"
|
||||||
|
rfd = "0.15"
|
||||||
|
regex = "1.11"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
rayon = "1.10"
|
||||||
|
chrono = "0.4"
|
||||||
26
Cross.toml
Normal file
26
Cross.toml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Cross-compilation configuration for the 'cross' tool
|
||||||
|
|
||||||
|
# Build configuration - ensure build scripts run in container
|
||||||
|
[build]
|
||||||
|
# Run build scripts in the cross container to avoid GLIBC mismatches
|
||||||
|
default-target = "x86_64-pc-windows-gnu"
|
||||||
|
|
||||||
|
# Windows GNU target (MinGW) - fully supported
|
||||||
|
[target.x86_64-pc-windows-gnu]
|
||||||
|
# Use edge image with newer GLIBC to avoid build script issues
|
||||||
|
image = "ghcr.io/cross-rs/x86_64-pc-windows-gnu:edge"
|
||||||
|
|
||||||
|
# Ensure build scripts run in the container
|
||||||
|
pre-build = [
|
||||||
|
"dpkg --add-architecture i386",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Build environment configuration
|
||||||
|
[build.env]
|
||||||
|
passthrough = [
|
||||||
|
"CARGO_BUILD_TARGET",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Note: MSVC target is not supported by cross from Linux
|
||||||
|
# MSVC requires proprietary Microsoft tools that only run on Windows
|
||||||
|
# Use the GNU target for Windows cross-compilation from Linux
|
||||||
58
Makefile
Normal file
58
Makefile
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
.PHONY: help build release clean test run check fmt clippy install dist cross-windows cross-all
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "RLogg - Available targets:"
|
||||||
|
@echo " make build - Build debug version"
|
||||||
|
@echo " make release - Build release version"
|
||||||
|
@echo " make dist - Build release for current platform"
|
||||||
|
@echo " make cross-windows - Cross-compile for Windows (requires Docker)"
|
||||||
|
@echo " make cross-all - Cross-compile for all platforms (requires Docker)"
|
||||||
|
@echo " make run - Run in development mode"
|
||||||
|
@echo " make test - Run tests"
|
||||||
|
@echo " make check - Check code without building"
|
||||||
|
@echo " make fmt - Format code"
|
||||||
|
@echo " make clippy - Run linter"
|
||||||
|
@echo " make clean - Clean build artifacts"
|
||||||
|
@echo " make install - Install binary to ~/.cargo/bin"
|
||||||
|
|
||||||
|
build:
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
release:
|
||||||
|
cargo build --release
|
||||||
|
@echo "Binary built at: target/release/rlogg"
|
||||||
|
|
||||||
|
dist:
|
||||||
|
@echo "Building release binaries for all platforms..."
|
||||||
|
./build-release.sh
|
||||||
|
|
||||||
|
run:
|
||||||
|
cargo run
|
||||||
|
|
||||||
|
test:
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
check:
|
||||||
|
cargo check
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
cargo fmt
|
||||||
|
|
||||||
|
clippy:
|
||||||
|
cargo clippy -- -D warnings
|
||||||
|
|
||||||
|
clean:
|
||||||
|
cargo clean
|
||||||
|
rm -rf dist/
|
||||||
|
|
||||||
|
install: release
|
||||||
|
cargo install --path .
|
||||||
|
@echo "Installed to ~/.cargo/bin/rlogg"
|
||||||
|
|
||||||
|
cross-windows:
|
||||||
|
@echo "Cross-compiling for Windows..."
|
||||||
|
./cross-compile.sh windows
|
||||||
|
|
||||||
|
cross-all:
|
||||||
|
@echo "Cross-compiling for all platforms..."
|
||||||
|
./cross-compile.sh all
|
||||||
70
build-release.ps1
Normal file
70
build-release.ps1
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Build script for creating release binaries for Windows
|
||||||
|
# PowerShell script for Windows users
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
Write-Host "RLogg - Multi-platform Release Builder (Windows)" -ForegroundColor Cyan
|
||||||
|
Write-Host "=" * 50 -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Create dist directory
|
||||||
|
$DistDir = "dist"
|
||||||
|
if (-not (Test-Path $DistDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $DistDir | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to build for a target
|
||||||
|
function Build-Target {
|
||||||
|
param (
|
||||||
|
[string]$Target,
|
||||||
|
[string]$Name
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Host "Building for $Name ($Target)..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
try {
|
||||||
|
cargo build --release --target $Target
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Host "✓ Build successful for $Name" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Copy binary to dist directory
|
||||||
|
if ($Target -like "*windows*") {
|
||||||
|
$SourcePath = "target\$Target\release\rlogg.exe"
|
||||||
|
$DestPath = "$DistDir\rlogg-$Name.exe"
|
||||||
|
} else {
|
||||||
|
$SourcePath = "target\$Target\release\rlogg"
|
||||||
|
$DestPath = "$DistDir\rlogg-$Name"
|
||||||
|
}
|
||||||
|
|
||||||
|
Copy-Item $SourcePath $DestPath -Force
|
||||||
|
Write-Host "✓ Binary copied to $DestPath" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "✗ Build failed for $Name" -ForegroundColor Red
|
||||||
|
Write-Host $_.Exception.Message -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build for Windows
|
||||||
|
Write-Host "=== Building for Windows ===" -ForegroundColor Cyan
|
||||||
|
$success = Build-Target "x86_64-pc-windows-msvc" "windows-x86_64"
|
||||||
|
|
||||||
|
if ($success) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=== Build Complete ===" -ForegroundColor Green
|
||||||
|
Write-Host "Binaries are in the '$DistDir' directory:" -ForegroundColor Green
|
||||||
|
Get-ChildItem $DistDir | Format-Table Name, Length, LastWriteTime
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "To build for additional platforms, install the target and run:"
|
||||||
|
Write-Host " rustup target add <target-triple>"
|
||||||
|
Write-Host " cargo build --release --target <target-triple>"
|
||||||
|
} else {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Build failed!" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
92
build-release.sh
Executable file
92
build-release.sh
Executable file
@@ -0,0 +1,92 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build script for creating release binaries for all platforms
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "RLogg - Multi-platform Release Builder"
|
||||||
|
echo "======================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Extract version from Cargo.toml
|
||||||
|
VERSION=$(grep -m1 '^version = ' Cargo.toml | sed 's/version = "\(.*\)"/\1/')
|
||||||
|
echo "Version: $VERSION"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Create dist directory
|
||||||
|
DIST_DIR="dist"
|
||||||
|
mkdir -p "$DIST_DIR"
|
||||||
|
|
||||||
|
# Function to build for a target
|
||||||
|
build_target() {
|
||||||
|
local target=$1
|
||||||
|
local name=$2
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Building for $name ($target)...${NC}"
|
||||||
|
|
||||||
|
if cargo build --release --target "$target"; then
|
||||||
|
echo -e "${GREEN}✓ Build successful for $name${NC}"
|
||||||
|
|
||||||
|
# Copy binary to dist directory with version
|
||||||
|
if [[ "$target" == *"windows"* ]]; then
|
||||||
|
cp "target/$target/release/rlogg.exe" "$DIST_DIR/rlogg-$VERSION-$name.exe"
|
||||||
|
echo -e "${GREEN}✓ Binary copied to $DIST_DIR/rlogg-$VERSION-$name.exe${NC}"
|
||||||
|
else
|
||||||
|
cp "target/$target/release/rlogg" "$DIST_DIR/rlogg-$VERSION-$name"
|
||||||
|
# Strip binary on Unix-like systems
|
||||||
|
strip "$DIST_DIR/rlogg-$VERSION-$name" 2>/dev/null || true
|
||||||
|
echo -e "${GREEN}✓ Binary copied to $DIST_DIR/rlogg-$VERSION-$name${NC}"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Build failed for $name${NC}"
|
||||||
|
echo ""
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect current platform
|
||||||
|
PLATFORM=$(uname -s)
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
|
||||||
|
echo "Current platform: $PLATFORM ($ARCH)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Build for current platform first
|
||||||
|
case "$PLATFORM" in
|
||||||
|
Linux)
|
||||||
|
echo "=== Building for Linux ==="
|
||||||
|
build_target "x86_64-unknown-linux-gnu" "linux-x86_64"
|
||||||
|
;;
|
||||||
|
Darwin)
|
||||||
|
echo "=== Building for macOS ==="
|
||||||
|
if [[ "$ARCH" == "arm64" ]]; then
|
||||||
|
build_target "aarch64-apple-darwin" "macos-aarch64"
|
||||||
|
else
|
||||||
|
build_target "x86_64-apple-darwin" "macos-x86_64"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
MINGW* | MSYS* | CYGWIN*)
|
||||||
|
echo "=== Building for Windows ==="
|
||||||
|
build_target "x86_64-pc-windows-msvc" "windows-x86_64"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Unknown platform: $PLATFORM${NC}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}=== Build Complete ===${NC}"
|
||||||
|
echo "Binaries are in the '$DIST_DIR' directory:"
|
||||||
|
ls -lh "$DIST_DIR"
|
||||||
|
echo ""
|
||||||
|
echo "To build for additional platforms, install the target and run:"
|
||||||
|
echo " rustup target add <target-triple>"
|
||||||
|
echo " cargo build --release --target <target-triple>"
|
||||||
161
cross-compile.sh
Executable file
161
cross-compile.sh
Executable file
@@ -0,0 +1,161 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Cross-compilation script for building Windows binaries on Linux
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "RLogg - Cross-Platform Builder (Linux → All Platforms)"
|
||||||
|
echo "======================================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Extract version from Cargo.toml
|
||||||
|
VERSION=$(grep -m1 '^version = ' Cargo.toml | sed 's/version = "\(.*\)"/\1/')
|
||||||
|
echo "Version: $VERSION"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Create dist directory
|
||||||
|
DIST_DIR="dist"
|
||||||
|
mkdir -p "$DIST_DIR"
|
||||||
|
|
||||||
|
# Check if cross is installed
|
||||||
|
check_cross() {
|
||||||
|
if ! command -v cross &> /dev/null; then
|
||||||
|
echo -e "${YELLOW}Installing 'cross' for cross-compilation...${NC}"
|
||||||
|
cargo install cross --git https://github.com/cross-rs/cross
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}✓ 'cross' is already installed${NC}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build using cross
|
||||||
|
build_with_cross() {
|
||||||
|
local target=$1
|
||||||
|
local name=$2
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}=== Building for $name ===${NC}"
|
||||||
|
echo -e "${YELLOW}Target: $target${NC}"
|
||||||
|
|
||||||
|
# Clean build artifacts to avoid GLIBC mismatch with build scripts
|
||||||
|
echo -e "${YELLOW}Cleaning build artifacts...${NC}"
|
||||||
|
cargo clean --release --target "$target"
|
||||||
|
|
||||||
|
if cross build --release --target "$target"; then
|
||||||
|
echo -e "${GREEN}✓ Build successful for $name${NC}"
|
||||||
|
|
||||||
|
# Copy binary to dist directory with version
|
||||||
|
if [[ "$target" == *"windows"* ]]; then
|
||||||
|
cp "target/$target/release/rlogg.exe" "$DIST_DIR/rlogg-$VERSION-$name.exe"
|
||||||
|
echo -e "${GREEN}✓ Binary: $DIST_DIR/rlogg-$VERSION-$name.exe${NC}"
|
||||||
|
else
|
||||||
|
cp "target/$target/release/rlogg" "$DIST_DIR/rlogg-$VERSION-$name"
|
||||||
|
strip "$DIST_DIR/rlogg-$VERSION-$name" 2>/dev/null || true
|
||||||
|
echo -e "${GREEN}✓ Binary: $DIST_DIR/rlogg-$VERSION-$name${NC}"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Build failed for $name${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build using regular cargo (for native Linux)
|
||||||
|
build_native() {
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}=== Building for Linux (native) ===${NC}"
|
||||||
|
|
||||||
|
if cargo build --release; then
|
||||||
|
echo -e "${GREEN}✓ Build successful for Linux${NC}"
|
||||||
|
cp "target/release/rlogg" "$DIST_DIR/rlogg-$VERSION-linux-x86_64"
|
||||||
|
strip "$DIST_DIR/rlogg-$VERSION-linux-x86_64" 2>/dev/null || true
|
||||||
|
echo -e "${GREEN}✓ Binary: $DIST_DIR/rlogg-$VERSION-linux-x86_64${NC}"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Build failed for Linux${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main menu
|
||||||
|
show_menu() {
|
||||||
|
echo ""
|
||||||
|
echo "Select targets to build:"
|
||||||
|
echo " 1) Linux x86_64 (native)"
|
||||||
|
echo " 2) Windows x86_64 (cross-compile with MinGW)"
|
||||||
|
echo " 3) Both Linux and Windows"
|
||||||
|
echo " 4) Exit"
|
||||||
|
echo ""
|
||||||
|
read -p "Enter choice [1-4]: " choice
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse command line arguments
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
# Interactive mode
|
||||||
|
while true; do
|
||||||
|
show_menu
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
1)
|
||||||
|
build_native
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
check_cross
|
||||||
|
build_with_cross "x86_64-pc-windows-gnu" "windows-x86_64"
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
build_native
|
||||||
|
check_cross
|
||||||
|
build_with_cross "x86_64-pc-windows-gnu" "windows-x86_64"
|
||||||
|
;;
|
||||||
|
4)
|
||||||
|
echo "Exiting..."
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Invalid choice${NC}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
else
|
||||||
|
# Command line mode
|
||||||
|
case "$1" in
|
||||||
|
linux)
|
||||||
|
build_native
|
||||||
|
;;
|
||||||
|
windows|win)
|
||||||
|
check_cross
|
||||||
|
build_with_cross "x86_64-pc-windows-gnu" "windows-x86_64"
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
build_native
|
||||||
|
check_cross
|
||||||
|
build_with_cross "x86_64-pc-windows-gnu" "windows-x86_64"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 [linux|windows|all]"
|
||||||
|
echo ""
|
||||||
|
echo " linux - Build for Linux (native)"
|
||||||
|
echo " windows - Cross-compile for Windows (MinGW)"
|
||||||
|
echo " all - Build for both platforms"
|
||||||
|
echo ""
|
||||||
|
echo "Or run without arguments for interactive mode"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}=== Build Complete ===${NC}"
|
||||||
|
if [ -d "$DIST_DIR" ] && [ "$(ls -A $DIST_DIR)" ]; then
|
||||||
|
echo "Binaries in '$DIST_DIR':"
|
||||||
|
ls -lh "$DIST_DIR"
|
||||||
|
else
|
||||||
|
echo "No binaries were built"
|
||||||
|
fi
|
||||||
51
src/config.rs
Normal file
51
src/config.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
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>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub date_range_enabled: bool,
|
||||||
|
#[serde(default = "default_date_format")]
|
||||||
|
pub date_format: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub date_from: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub date_to: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_date_format() -> String {
|
||||||
|
String::from("%Y-%m-%d %H:%M:%S")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/line_index.rs
Normal file
90
src/line_index.rs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a range of lines efficiently by seeking once and reading sequentially
|
||||||
|
pub fn read_line_range(
|
||||||
|
&self,
|
||||||
|
file: &mut BufReader<File>,
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
) -> Vec<(usize, String)> {
|
||||||
|
let end = end.min(self.total_lines);
|
||||||
|
if start >= end || start >= self.total_lines {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut results = Vec::with_capacity(end - start);
|
||||||
|
|
||||||
|
// Seek to the start position once
|
||||||
|
let start_pos = self.positions[start];
|
||||||
|
if file.seek(SeekFrom::Start(start_pos)).is_err() {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read lines sequentially without additional seeks
|
||||||
|
let mut line = String::new();
|
||||||
|
for line_num in start..end {
|
||||||
|
line.clear();
|
||||||
|
if file.read_line(&mut line).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if line.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
results.push((line_num, line.trim_end_matches(['\r', '\n']).to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
}
|
||||||
501
src/main.rs
Normal file
501
src/main.rs
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
// Hide console window on Windows in release builds
|
||||||
|
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod file_tab;
|
||||||
|
mod highlight;
|
||||||
|
mod line_index;
|
||||||
|
mod search;
|
||||||
|
mod tab_manager;
|
||||||
|
mod theme;
|
||||||
|
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| {
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
336
src/search.rs
Normal file
336
src/search.rs
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
use rayon::prelude::*;
|
||||||
|
use regex::Regex;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::BufReader;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread;
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
|
||||||
|
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,
|
||||||
|
pub date_range_enabled: bool,
|
||||||
|
pub date_format: String,
|
||||||
|
pub date_from: String,
|
||||||
|
pub date_to: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 format_to_regex(format: &str) -> Option<String> {
|
||||||
|
// Convert chrono format to regex pattern
|
||||||
|
let mut regex = format.to_string();
|
||||||
|
regex = regex.replace("%Y", r"\d{4}"); // 4-digit year
|
||||||
|
regex = regex.replace("%m", r"\d{2}"); // 2-digit month
|
||||||
|
regex = regex.replace("%d", r"\d{2}"); // 2-digit day
|
||||||
|
regex = regex.replace("%H", r"\d{2}"); // 2-digit hour
|
||||||
|
regex = regex.replace("%M", r"\d{2}"); // 2-digit minute
|
||||||
|
regex = regex.replace("%S", r"\d{2}"); // 2-digit second
|
||||||
|
regex = regex.replace("%I", r"\d{2}"); // 2-digit hour (12h)
|
||||||
|
regex = regex.replace("%p", r"(AM|PM)"); // AM/PM
|
||||||
|
Some(regex)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_and_parse_date(content: &str, format: &str) -> Option<NaiveDateTime> {
|
||||||
|
// Convert format to regex pattern
|
||||||
|
let pattern = format_to_regex(format)?;
|
||||||
|
let regex = Regex::new(&pattern).ok()?;
|
||||||
|
let date_str = regex.captures(content)?.get(0)?.as_str();
|
||||||
|
|
||||||
|
// Parse using the provided format
|
||||||
|
NaiveDateTime::parse_from_str(date_str, format).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_line_with_date(
|
||||||
|
line_index: &LineIndex,
|
||||||
|
file_handle: &mut BufReader<File>,
|
||||||
|
start_line: usize,
|
||||||
|
format: &str,
|
||||||
|
move_down: bool,
|
||||||
|
) -> Option<(usize, NaiveDateTime)> {
|
||||||
|
let max_search = 1000; // Search up to 10 lines
|
||||||
|
let range: Box<dyn Iterator<Item = usize>> = if move_down {
|
||||||
|
Box::new(start_line..std::cmp::min(start_line + max_search, line_index.total_lines))
|
||||||
|
} else {
|
||||||
|
Box::new((start_line.saturating_sub(max_search)..=start_line).rev())
|
||||||
|
};
|
||||||
|
|
||||||
|
for line_num in range {
|
||||||
|
if let Some(content) = line_index.read_line(file_handle, line_num) {
|
||||||
|
if let Some(date) = extract_and_parse_date(&content, format) {
|
||||||
|
return Some((line_num, date));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn binary_search_date(
|
||||||
|
line_index: &LineIndex,
|
||||||
|
file_handle: &mut BufReader<File>,
|
||||||
|
target_date: &NaiveDateTime,
|
||||||
|
format: &str,
|
||||||
|
find_first: bool, // true = find first occurrence, false = find last
|
||||||
|
) -> Option<usize> {
|
||||||
|
let mut left = 0;
|
||||||
|
let mut right = line_index.total_lines;
|
||||||
|
let mut result = None;
|
||||||
|
|
||||||
|
while left < right {
|
||||||
|
let mid = (left + right) / 2;
|
||||||
|
|
||||||
|
// Find a line with a date starting from mid, moving down
|
||||||
|
match find_line_with_date(line_index, file_handle, mid, format, true) {
|
||||||
|
Some((line_with_date, date)) => {
|
||||||
|
if find_first {
|
||||||
|
// Finding first line >= target_date
|
||||||
|
if date >= *target_date {
|
||||||
|
result = Some(line_with_date);
|
||||||
|
right = mid;
|
||||||
|
} else {
|
||||||
|
left = mid + 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Finding last line <= target_date
|
||||||
|
if date <= *target_date {
|
||||||
|
result = Some(line_with_date);
|
||||||
|
// Ensure we make progress in the binary search
|
||||||
|
left = mid + 1;
|
||||||
|
} else {
|
||||||
|
right = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Can't find a date near mid, try searching in a larger range
|
||||||
|
// or skip this section
|
||||||
|
if find_first {
|
||||||
|
// When finding first, if we can't find a date, try the right half
|
||||||
|
left = mid + 1;
|
||||||
|
} else {
|
||||||
|
// When finding last, if we can't find a date, try the left half
|
||||||
|
right = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_date_range(
|
||||||
|
params: &SearchParams,
|
||||||
|
line_index: &LineIndex,
|
||||||
|
file_path: &Path,
|
||||||
|
) -> Option<(usize, usize)> {
|
||||||
|
if !params.date_range_enabled || params.date_from.is_empty() || params.date_to.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the target dates using the provided format
|
||||||
|
let date_from = NaiveDateTime::parse_from_str(¶ms.date_from, ¶ms.date_format).ok()?;
|
||||||
|
let date_to = NaiveDateTime::parse_from_str(¶ms.date_to, ¶ms.date_format).ok()?;
|
||||||
|
|
||||||
|
let file = File::open(file_path).ok()?;
|
||||||
|
let mut file_handle = BufReader::new(file);
|
||||||
|
|
||||||
|
// Binary search for first line >= date_from
|
||||||
|
let start_line = binary_search_date(line_index, &mut file_handle, &date_from, ¶ms.date_format, true)?;
|
||||||
|
|
||||||
|
// Binary search for last line <= date_to
|
||||||
|
let end_line = binary_search_date(line_index, &mut file_handle, &date_to, ¶ms.date_format, false)?;
|
||||||
|
|
||||||
|
if start_line < end_line {
|
||||||
|
Some((start_line, end_line + 1)) // +1 to include the end line
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_lines(
|
||||||
|
params: &SearchParams,
|
||||||
|
line_index: &LineIndex,
|
||||||
|
file_path: &Path,
|
||||||
|
progress: &Arc<Mutex<f32>>,
|
||||||
|
) -> Vec<FilteredLine> {
|
||||||
|
let total_lines = line_index.total_lines;
|
||||||
|
if total_lines == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the line range to search (all lines or date range)
|
||||||
|
let (search_start, search_end) = if let Some((start, end)) = find_date_range(params, line_index, file_path) {
|
||||||
|
(start, end)
|
||||||
|
} else {
|
||||||
|
(0, total_lines)
|
||||||
|
};
|
||||||
|
|
||||||
|
let lines_to_search = search_end - search_start;
|
||||||
|
if lines_to_search == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine optimal chunk size based on lines to search
|
||||||
|
let num_threads = rayon::current_num_threads();
|
||||||
|
let min_chunk_size = 1000;
|
||||||
|
let chunk_size = (lines_to_search / (num_threads * 4)).max(min_chunk_size);
|
||||||
|
|
||||||
|
// Split line numbers into chunks within the search range
|
||||||
|
let chunks: Vec<(usize, usize)> = (search_start..search_end)
|
||||||
|
.step_by(chunk_size)
|
||||||
|
.map(|start| {
|
||||||
|
let end = (start + chunk_size).min(search_end);
|
||||||
|
(start, end)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let total_chunks = chunks.len();
|
||||||
|
let processed_chunks = Arc::new(Mutex::new(0usize));
|
||||||
|
|
||||||
|
// Process chunks in parallel
|
||||||
|
let results: Vec<Vec<FilteredLine>> = chunks
|
||||||
|
.par_iter()
|
||||||
|
.filter_map(|(start, end)| {
|
||||||
|
// Each thread opens its own file handle
|
||||||
|
let file = File::open(file_path).ok()?;
|
||||||
|
let mut file_handle = BufReader::new(file);
|
||||||
|
let regex_matcher = params.build_regex_matcher();
|
||||||
|
let mut chunk_results = Vec::new();
|
||||||
|
|
||||||
|
// Read lines in this chunk efficiently (one seek, sequential reads)
|
||||||
|
let lines = line_index.read_line_range(&mut file_handle, *start, *end);
|
||||||
|
|
||||||
|
// Process each line - only store line numbers, not content
|
||||||
|
for (line_number, content) in lines {
|
||||||
|
// If date range is enabled and query is empty, include all lines in range
|
||||||
|
let should_include = if params.date_range_enabled && params.query.is_empty() {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
params.matches_line(&content, ®ex_matcher)
|
||||||
|
};
|
||||||
|
|
||||||
|
if should_include {
|
||||||
|
chunk_results.push(FilteredLine { line_number });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
{
|
||||||
|
let mut count = processed_chunks.lock().unwrap();
|
||||||
|
*count += 1;
|
||||||
|
*progress.lock().unwrap() = *count as f32 / total_chunks as f32;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(chunk_results)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Flatten and sort results by line number
|
||||||
|
let mut filtered: Vec<FilteredLine> = results.into_iter().flatten().collect();
|
||||||
|
filtered.sort_by_key(|f| f.line_number);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
178
src/theme.rs
Normal file
178
src/theme.rs
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
/// Modern dark purple color palette
|
||||||
|
pub struct ColorPalette {
|
||||||
|
// Background colors
|
||||||
|
pub bg_primary: egui::Color32, // Main background
|
||||||
|
pub bg_secondary: egui::Color32, // Secondary panels
|
||||||
|
pub bg_tertiary: egui::Color32, // Elevated elements
|
||||||
|
|
||||||
|
// Accent colors
|
||||||
|
pub accent_primary: egui::Color32, // Primary purple accent
|
||||||
|
pub accent_secondary: egui::Color32, // Secondary accent
|
||||||
|
pub accent_bright: egui::Color32, // Bright highlights
|
||||||
|
|
||||||
|
// Text colors
|
||||||
|
pub text_primary: egui::Color32,
|
||||||
|
pub text_secondary: egui::Color32,
|
||||||
|
pub text_muted: egui::Color32,
|
||||||
|
|
||||||
|
// UI element colors
|
||||||
|
pub selection: egui::Color32,
|
||||||
|
pub line_number: egui::Color32,
|
||||||
|
pub border: egui::Color32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColorPalette {
|
||||||
|
pub fn dark_purple() -> Self {
|
||||||
|
Self {
|
||||||
|
// Deep purple-gray backgrounds
|
||||||
|
bg_primary: egui::Color32::from_rgb(24, 20, 32), // #18141F
|
||||||
|
bg_secondary: egui::Color32::from_rgb(32, 26, 42), // #201A2A
|
||||||
|
bg_tertiary: egui::Color32::from_rgb(42, 35, 54), // #2A2336
|
||||||
|
|
||||||
|
// Purple accents - modern and vibrant
|
||||||
|
accent_primary: egui::Color32::from_rgb(138, 98, 208), // #8A62D0
|
||||||
|
accent_secondary: egui::Color32::from_rgb(108, 68, 178), // #6C44B2
|
||||||
|
accent_bright: egui::Color32::from_rgb(168, 128, 238), // #A880EE
|
||||||
|
|
||||||
|
// Text colors with good contrast
|
||||||
|
text_primary: egui::Color32::from_rgb(230, 230, 240), // #E6E6F0
|
||||||
|
text_secondary: egui::Color32::from_rgb(190, 190, 210), // #BEBED2
|
||||||
|
text_muted: egui::Color32::from_rgb(140, 140, 165), // #8C8CA5
|
||||||
|
|
||||||
|
// UI elements
|
||||||
|
selection: egui::Color32::from_rgb(108, 68, 178), // #6C44B2
|
||||||
|
line_number: egui::Color32::from_rgb(110, 100, 130), // #6E6482
|
||||||
|
border: egui::Color32::from_rgb(60, 50, 75), // #3C324B
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the dark purple theme to the egui context
|
||||||
|
pub fn apply_theme(ctx: &egui::Context) {
|
||||||
|
let palette = ColorPalette::dark_purple();
|
||||||
|
|
||||||
|
let mut style = (*ctx.style()).clone();
|
||||||
|
|
||||||
|
// === Spacing and sizing for better UX ===
|
||||||
|
style.spacing.item_spacing = egui::vec2(8.0, 6.0);
|
||||||
|
style.spacing.button_padding = egui::vec2(12.0, 6.0);
|
||||||
|
style.spacing.menu_margin = egui::Margin::same(8.0);
|
||||||
|
style.spacing.indent = 20.0;
|
||||||
|
style.spacing.scroll = egui::style::ScrollStyle {
|
||||||
|
bar_width: 10.0,
|
||||||
|
handle_min_length: 20.0,
|
||||||
|
bar_inner_margin: 2.0,
|
||||||
|
bar_outer_margin: 0.0,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Color scheme ===
|
||||||
|
let visuals = &mut style.visuals;
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
visuals.dark_mode = true;
|
||||||
|
|
||||||
|
// Window and panel backgrounds
|
||||||
|
visuals.window_fill = palette.bg_primary;
|
||||||
|
visuals.panel_fill = palette.bg_primary;
|
||||||
|
visuals.faint_bg_color = palette.bg_secondary;
|
||||||
|
visuals.extreme_bg_color = palette.bg_tertiary;
|
||||||
|
|
||||||
|
// Text colors (using override_text_color to set custom text color)
|
||||||
|
visuals.override_text_color = Some(palette.text_primary);
|
||||||
|
|
||||||
|
// Widget colors
|
||||||
|
visuals.widgets.noninteractive.bg_fill = palette.bg_secondary;
|
||||||
|
visuals.widgets.noninteractive.weak_bg_fill = palette.bg_secondary;
|
||||||
|
visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, palette.border);
|
||||||
|
visuals.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, palette.text_secondary);
|
||||||
|
|
||||||
|
// Inactive/hovered widgets
|
||||||
|
visuals.widgets.inactive.bg_fill = palette.bg_secondary;
|
||||||
|
visuals.widgets.inactive.weak_bg_fill = palette.bg_secondary;
|
||||||
|
visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, palette.border);
|
||||||
|
visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, palette.text_primary);
|
||||||
|
|
||||||
|
visuals.widgets.hovered.bg_fill = palette.bg_tertiary;
|
||||||
|
visuals.widgets.hovered.weak_bg_fill = palette.bg_tertiary;
|
||||||
|
visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.5, palette.accent_primary);
|
||||||
|
visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.5, palette.text_primary);
|
||||||
|
|
||||||
|
// Active/clicked widgets
|
||||||
|
visuals.widgets.active.bg_fill = palette.accent_secondary;
|
||||||
|
visuals.widgets.active.weak_bg_fill = palette.accent_secondary;
|
||||||
|
visuals.widgets.active.bg_stroke = egui::Stroke::new(2.0, palette.accent_bright);
|
||||||
|
visuals.widgets.active.fg_stroke = egui::Stroke::new(2.0, palette.text_primary);
|
||||||
|
|
||||||
|
visuals.widgets.open.bg_fill = palette.accent_secondary;
|
||||||
|
visuals.widgets.open.weak_bg_fill = palette.accent_secondary;
|
||||||
|
visuals.widgets.open.bg_stroke = egui::Stroke::new(1.5, palette.accent_primary);
|
||||||
|
visuals.widgets.open.fg_stroke = egui::Stroke::new(1.5, palette.text_primary);
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
visuals.selection.bg_fill = palette.selection;
|
||||||
|
visuals.selection.stroke = egui::Stroke::new(1.0, palette.accent_bright);
|
||||||
|
|
||||||
|
// Hyperlinks
|
||||||
|
visuals.hyperlink_color = palette.accent_bright;
|
||||||
|
|
||||||
|
// Window styling
|
||||||
|
visuals.window_rounding = egui::Rounding::same(8.0);
|
||||||
|
visuals.window_shadow = egui::epaint::Shadow {
|
||||||
|
offset: egui::vec2(0.0, 8.0),
|
||||||
|
blur: 20.0,
|
||||||
|
spread: 0.0,
|
||||||
|
color: egui::Color32::from_black_alpha(80),
|
||||||
|
};
|
||||||
|
visuals.window_stroke = egui::Stroke::new(1.0, palette.border);
|
||||||
|
|
||||||
|
// Popup styling
|
||||||
|
visuals.popup_shadow = egui::epaint::Shadow {
|
||||||
|
offset: egui::vec2(0.0, 4.0),
|
||||||
|
blur: 16.0,
|
||||||
|
spread: 0.0,
|
||||||
|
color: egui::Color32::from_black_alpha(100),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resize handle
|
||||||
|
visuals.resize_corner_size = 12.0;
|
||||||
|
|
||||||
|
// Menu rounding
|
||||||
|
visuals.menu_rounding = egui::Rounding::same(6.0);
|
||||||
|
|
||||||
|
// Indent guide
|
||||||
|
visuals.indent_has_left_vline = true;
|
||||||
|
visuals.striped = true;
|
||||||
|
|
||||||
|
// Borders and separators
|
||||||
|
visuals.window_stroke = egui::Stroke::new(1.0, palette.border);
|
||||||
|
|
||||||
|
// === Text styles with better readability ===
|
||||||
|
style.text_styles.insert(
|
||||||
|
egui::TextStyle::Body,
|
||||||
|
egui::FontId::proportional(14.0),
|
||||||
|
);
|
||||||
|
style.text_styles.insert(
|
||||||
|
egui::TextStyle::Button,
|
||||||
|
egui::FontId::proportional(14.0),
|
||||||
|
);
|
||||||
|
style.text_styles.insert(
|
||||||
|
egui::TextStyle::Heading,
|
||||||
|
egui::FontId::proportional(18.0),
|
||||||
|
);
|
||||||
|
style.text_styles.insert(
|
||||||
|
egui::TextStyle::Monospace,
|
||||||
|
egui::FontId::monospace(13.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply the style
|
||||||
|
ctx.set_style(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the color palette for use in custom rendering
|
||||||
|
pub fn get_palette() -> ColorPalette {
|
||||||
|
ColorPalette::dark_purple()
|
||||||
|
}
|
||||||
15
src/types.rs
Normal file
15
src/types.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
pub struct HighlightRule {
|
||||||
|
pub pattern: String,
|
||||||
|
pub color: [u8; 3],
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtered lines now only store line numbers to save memory
|
||||||
|
// Content is loaded on-demand when rendering
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FilteredLine {
|
||||||
|
pub line_number: usize,
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
259
src/ui/log_view.rs
Normal file
259
src/ui/log_view.rs
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
use crate::file_tab::FileTab;
|
||||||
|
use crate::theme;
|
||||||
|
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 + 6f32;
|
||||||
|
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 row_height_offset = row_height + 6f32;
|
||||||
|
let viewport_height = ui.available_height();
|
||||||
|
let rows_per_page = (viewport_height / row_height_offset).floor().max(1.0);
|
||||||
|
let scroll_delta = direction * rows_per_page * row_height_offset;
|
||||||
|
|
||||||
|
let max_offset = (total_lines as f32 * row_height_offset - 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 {
|
||||||
|
// Main view: read line by display index
|
||||||
|
let content = tab
|
||||||
|
.line_index
|
||||||
|
.read_line(&mut tab.file_handle, display_idx)
|
||||||
|
.unwrap_or_default();
|
||||||
|
(display_idx, content)
|
||||||
|
} else {
|
||||||
|
// Filtered view: get line number from filtered list, then read content on-demand
|
||||||
|
if display_idx < tab.filtered_lines.len() {
|
||||||
|
let line_number = tab.filtered_lines[display_idx].line_number;
|
||||||
|
let content = tab
|
||||||
|
.line_index
|
||||||
|
.read_line(&mut tab.file_handle, line_number)
|
||||||
|
.unwrap_or_default();
|
||||||
|
(line_number, content)
|
||||||
|
} 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 palette = theme::get_palette();
|
||||||
|
|
||||||
|
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]));
|
||||||
|
|
||||||
|
// Improved color scheme with better visual hierarchy
|
||||||
|
let bg_color = if is_selected {
|
||||||
|
palette.selection
|
||||||
|
} else if let Some(color) = highlight_color {
|
||||||
|
color
|
||||||
|
} else {
|
||||||
|
egui::Color32::TRANSPARENT
|
||||||
|
};
|
||||||
|
|
||||||
|
// Better padding and margins for improved readability
|
||||||
|
let frame = egui::Frame::none()
|
||||||
|
.fill(bg_color)
|
||||||
|
.inner_margin(egui::Margin::symmetric(8.0, 3.0));
|
||||||
|
|
||||||
|
frame.show(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
// Line numbers with better styling
|
||||||
|
let line_num_text = egui::RichText::new(format!("{:6}", line_num + 1))
|
||||||
|
.monospace()
|
||||||
|
.color(if is_selected {
|
||||||
|
palette.text_primary
|
||||||
|
} else {
|
||||||
|
palette.line_number
|
||||||
|
});
|
||||||
|
|
||||||
|
let line_num_response = ui.label(line_num_text);
|
||||||
|
|
||||||
|
// Separator between line number and content
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.separator();
|
||||||
|
ui.add_space(4.0);
|
||||||
|
|
||||||
|
// Content text with improved styling
|
||||||
|
let text = egui::RichText::new(content).monospace().color(
|
||||||
|
if is_selected {
|
||||||
|
palette.text_primary
|
||||||
|
} else {
|
||||||
|
palette.text_primary
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let text_response = ui
|
||||||
|
.scope(|ui| {
|
||||||
|
// Better text selection colors
|
||||||
|
ui.style_mut().visuals.selection.bg_fill = palette.accent_bright;
|
||||||
|
ui.style_mut().visuals.selection.stroke.color = palette.accent_primary;
|
||||||
|
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;
|
||||||
173
src/ui/search_panel.rs
Normal file
173
src/ui/search_panel.rs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
use crate::search::SearchState;
|
||||||
|
use crate::theme;
|
||||||
|
|
||||||
|
pub struct SearchPanelState {
|
||||||
|
pub query: String,
|
||||||
|
pub case_sensitive: bool,
|
||||||
|
pub use_regex: bool,
|
||||||
|
pub history: Vec<String>,
|
||||||
|
pub date_range_enabled: bool,
|
||||||
|
pub date_format: String,
|
||||||
|
pub date_from: String,
|
||||||
|
pub date_to: 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,
|
||||||
|
request_focus: bool,
|
||||||
|
) -> SearchPanelActions {
|
||||||
|
let mut actions = SearchPanelActions {
|
||||||
|
execute_search: false,
|
||||||
|
clear_search: false,
|
||||||
|
config_changed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let palette = theme::get_palette();
|
||||||
|
|
||||||
|
egui::TopBottomPanel::bottom("search_panel")
|
||||||
|
.frame(egui::Frame::none()
|
||||||
|
.fill(palette.bg_secondary)
|
||||||
|
.inner_margin(egui::Margin::symmetric(12.0, 10.0)))
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
// Filter label with icon
|
||||||
|
let label = egui::RichText::new("🔍 Filter:")
|
||||||
|
.size(14.0)
|
||||||
|
.color(palette.text_primary);
|
||||||
|
ui.label(label);
|
||||||
|
|
||||||
|
ui.add_space(4.0);
|
||||||
|
|
||||||
|
// Text input with proper height matching buttons and stable ID
|
||||||
|
let text_edit_width = 300.0;
|
||||||
|
let search_input_id = egui::Id::new("search_input_field");
|
||||||
|
let text_response = ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut state.query)
|
||||||
|
.id(search_input_id)
|
||||||
|
.desired_width(text_edit_width)
|
||||||
|
.hint_text("Enter search query...")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Request focus if Ctrl+F was pressed
|
||||||
|
if request_focus {
|
||||||
|
text_response.request_focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
// Checkboxes
|
||||||
|
let case_changed = ui
|
||||||
|
.checkbox(&mut state.case_sensitive, "Case sensitive")
|
||||||
|
.changed();
|
||||||
|
let regex_changed = ui.checkbox(&mut state.use_regex, "Regex").changed();
|
||||||
|
let date_range_changed = ui.checkbox(&mut state.date_range_enabled, "Date range").changed();
|
||||||
|
|
||||||
|
if case_changed || regex_changed || date_range_changed {
|
||||||
|
actions.config_changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
// Search button
|
||||||
|
if ui.button("Search").clicked() || enter_pressed {
|
||||||
|
actions.execute_search = true;
|
||||||
|
actions.config_changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear button and match count
|
||||||
|
if !state.query.is_empty() {
|
||||||
|
if ui.button("✖ Clear").clicked() {
|
||||||
|
actions.clear_search = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
let count_text = egui::RichText::new(format!("{} matches", match_count))
|
||||||
|
.color(palette.accent_bright)
|
||||||
|
.size(13.0);
|
||||||
|
ui.label(count_text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Date range fields (show when date_range_enabled is true)
|
||||||
|
if state.date_range_enabled {
|
||||||
|
ui.add_space(6.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Format:");
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut state.date_format)
|
||||||
|
.desired_width(200.0)
|
||||||
|
.hint_text("%Y-%m-%d %H:%M:%S")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("From:");
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut state.date_from)
|
||||||
|
.desired_width(180.0)
|
||||||
|
.hint_text("2025-01-01 00:00:00")
|
||||||
|
);
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
ui.label("To:");
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut state.date_to)
|
||||||
|
.desired_width(180.0)
|
||||||
|
.hint_text("2025-01-01 01:00:00")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
if search_state.is_searching() {
|
||||||
|
ui.add_space(6.0);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
107
src/ui/tabs_panel.rs
Normal file
107
src/ui/tabs_panel.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
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| {
|
||||||
|
// Get or initialize scroll offset from persistent storage
|
||||||
|
let scroll_id = egui::Id::new("tabs_scroll_offset");
|
||||||
|
let mut scroll_offset: f32 = ui.ctx().data_mut(|d| d.get_persisted(scroll_id).unwrap_or(0.0));
|
||||||
|
|
||||||
|
// Use horizontal ScrollArea for tabs with hidden scrollbar
|
||||||
|
let mut scroll_area = egui::ScrollArea::horizontal()
|
||||||
|
.auto_shrink([false; 2])
|
||||||
|
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden);
|
||||||
|
|
||||||
|
// Apply the stored scroll offset
|
||||||
|
scroll_area = scroll_area.horizontal_scroll_offset(scroll_offset);
|
||||||
|
|
||||||
|
let scroll_output = scroll_area.show(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
for (idx, tab) in tabs.iter().enumerate() {
|
||||||
|
let is_active = idx == *active_tab_index;
|
||||||
|
let filename = tab.filename();
|
||||||
|
|
||||||
|
// Truncate filename if too long (max 20 characters)
|
||||||
|
let display_name = if filename.len() > 20 {
|
||||||
|
format!("{}...", &filename[..17])
|
||||||
|
} else {
|
||||||
|
filename.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let button_text = if is_active {
|
||||||
|
egui::RichText::new(format!("📄 {}", display_name)).strong()
|
||||||
|
} else {
|
||||||
|
egui::RichText::new(format!("📄 {}", display_name))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fixed width for tab label (150 pixels)
|
||||||
|
let tab_response = ui.add_sized(
|
||||||
|
[150.0, ui.available_height()],
|
||||||
|
egui::SelectableLabel::new(is_active, button_text)
|
||||||
|
);
|
||||||
|
|
||||||
|
if tab_response.clicked() {
|
||||||
|
*active_tab_index = idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show full filename on hover
|
||||||
|
if filename.len() > 20 {
|
||||||
|
tab_response.on_hover_text(&filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui.small_button("✖").clicked() {
|
||||||
|
*on_close_tab = Some(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if content overflows (tabs exceed screen width)
|
||||||
|
let content_width = scroll_output.content_size.x;
|
||||||
|
let viewport_width = scroll_output.inner_rect.width();
|
||||||
|
let content_overflows = content_width > viewport_width;
|
||||||
|
|
||||||
|
// Handle mouse wheel scrolling when hovering over tabs and content overflows
|
||||||
|
let mut should_update_offset = false;
|
||||||
|
if content_overflows && ui.rect_contains_pointer(scroll_output.inner_rect) {
|
||||||
|
// Get raw scroll delta outside of any closures
|
||||||
|
let raw_scroll_y = ui.input(|i| i.raw_scroll_delta.y);
|
||||||
|
|
||||||
|
// Check for raw mouse wheel events
|
||||||
|
if raw_scroll_y != 0.0 {
|
||||||
|
// Use vertical scroll (mouse wheel) for horizontal scrolling
|
||||||
|
let scroll_amount = -raw_scroll_y * 2.0;
|
||||||
|
scroll_offset = (scroll_offset + scroll_amount).max(0.0);
|
||||||
|
|
||||||
|
// Clamp to valid range
|
||||||
|
let max_offset = (content_width - viewport_width).max(0.0);
|
||||||
|
scroll_offset = scroll_offset.min(max_offset);
|
||||||
|
|
||||||
|
should_update_offset = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Update offset from ScrollArea state (in case of other interactions)
|
||||||
|
scroll_offset = scroll_output.state.offset.x;
|
||||||
|
should_update_offset = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the offset outside of any input closures
|
||||||
|
if should_update_offset {
|
||||||
|
ui.ctx().data_mut(|d| {
|
||||||
|
d.insert_persisted(scroll_id, scroll_offset);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
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