mod app;
mod config;
mod data;
mod event;
mod filter;
mod ui;
use std::io;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{bail, Context, Result};
use clap::Parser;
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use app::{group_digits, App};
#[derive(Parser, Debug)]
#[command(name = "jsonl-tui", version, about)]
struct Cli {
file: PathBuf,
#[arg(long)]
max_lines: Option<usize>,
#[arg(long)]
profile: Option<String>,
#[arg(long)]
filter: Option<String>,
#[arg(long)]
search: Option<String>,
#[arg(long)]
group: Option<String>,
#[arg(long)]
export: Option<PathBuf>,
#[arg(long)]
no_mouse: bool,
}
fn main() -> Result<()> {
let cli = Cli::parse();
let dataset = data::load_jsonl(&cli.file, cli.max_lines)?;
let load_summary = format!(
"Loaded {} records from {}{}",
group_digits(dataset.records.len()),
cli.file.display(),
if dataset.parse_errors > 0 {
format!(" ({} malformed lines skipped)", group_digits(dataset.parse_errors))
} else {
String::new()
}
);
let mut app = App::new(dataset, cli.file.clone());
if let Some(name) = &cli.profile {
let profile = config::load_profile(name)
.with_context(|| format!("failed to load profile '{name}'"))?;
app.apply_profile(&profile);
app.status = Some(format!("Applied profile '{name}'"));
} else if let Ok(profile) = config::load_shape_profile(&app.shape_hash) {
app.apply_profile(&profile);
app.status = Some("Applied saved view for this file shape".to_string());
}
if let Some(f) = &cli.filter {
app.filter_input = f.clone();
}
if let Some(s) = &cli.search {
app.search_input = s.clone();
}
let cli_facet = if let Some(g) = &cli.group {
let (field, facet) = match g.split_once('=') {
Some((f, v)) => (f.to_string(), Some(v.to_string())),
None => (g.clone(), None),
};
if !app.dataset.schema.contains_key(&field) {
bail!("unknown group field '{field}' (not present in this file)");
}
app.group_field = Some(field);
app.group_facet = facet.clone();
app.sync_group_input();
facet
} else {
None
};
app.recompute();
if let Some(err) = &app.filter_error {
bail!("invalid --filter expression: {err}");
}
if let Some(err) = &app.search_error {
bail!("invalid --search expression: {err}");
}
if let Some(facet) = &cli_facet {
if app.group_facet.is_none() {
bail!("group value '{facet}' matches no records (after filters)");
}
}
if let Some(out) = &cli.export {
let n = app.export_to(out)?;
println!("Wrote {} records to {}", group_digits(n), out.display());
return Ok(());
}
if app.status.is_none() {
app.status = Some(load_summary);
}
run_tui(&mut app, !cli.no_mouse)
}
fn run_tui(app: &mut App, mouse: bool) -> Result<()> {
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), DisableMouseCapture, LeaveAlternateScreen);
default_hook(info);
}));
enable_raw_mode().context("failed to enable raw mode")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).context("failed to enter alternate screen")?;
if mouse {
execute!(stdout, EnableMouseCapture).context("failed to enable mouse capture")?;
}
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).context("failed to create terminal")?;
let result = event_loop(&mut terminal, app);
disable_raw_mode().ok();
execute!(terminal.backend_mut(), DisableMouseCapture, LeaveAlternateScreen).ok();
terminal.show_cursor().ok();
let _ = std::panic::take_hook();
result
}
fn event_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
) -> Result<()> {
loop {
terminal.draw(|f| ui::draw(f, app))?;
if crossterm::event::poll(Duration::from_millis(200))? {
match crossterm::event::read()? {
crossterm::event::Event::Key(key) => event::handle_key(app, key),
crossterm::event::Event::Mouse(me) => event::handle_mouse(app, me),
crossterm::event::Event::Resize(_, _) => {}
_ => {}
}
}
if app.should_quit {
return Ok(());
}
}
}