mod app;
mod config;
mod diff;
mod i18n;
mod keys;
mod picker;
mod review;
mod review_session;
mod source;
mod ui;
use anyhow::{Context, Result};
use app::App;
use clap::Parser;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use source::{DiffSource, FilePair, GitSource};
use std::{
io,
path::PathBuf,
time::{Duration, Instant},
};
#[derive(Parser, Debug)]
#[command(
version,
about,
long_about = None,
after_help = "EXAMPLES:\n \
lziff working tree vs HEAD/index\n \
lziff --staged staged changes (index vs HEAD)\n \
lziff --commit HEAD what HEAD changed (HEAD~ vs HEAD)\n \
lziff --commit abc123 what abc123 changed (abc123~ vs abc123)\n \
lziff --range main..feature what feature added on top of main\n \
lziff --review 42 open PR #42 from origin (gh CLI)\n \
lziff --review URL open PR by URL\n \
lziff a.txt b.txt arbitrary file pair"
)]
struct Cli {
#[arg(num_args = 0..=2)]
paths: Vec<PathBuf>,
#[arg(long, alias = "cached", conflicts_with_all = ["commit", "range", "review"])]
staged: bool,
#[arg(short = 'c', long, value_name = "REV", conflicts_with_all = ["staged", "range", "review"])]
commit: Option<String>,
#[arg(
short = 'r',
long,
value_name = "FROM..TO",
conflicts_with_all = ["staged", "commit", "review"]
)]
range: Option<String>,
#[arg(
long,
value_name = "PR",
num_args = 0..=1,
default_missing_value = "",
conflicts_with_all = ["staged", "commit", "range"]
)]
review: Option<String>,
#[arg(long, requires = "review")]
all: bool,
}
fn main() -> Result<()> {
let cli = Cli::parse();
let prep = prepare(&cli)?;
let cfg = config::Config::load();
let strings = i18n::Strings::for_lang(&cfg.i18n.lang);
let mut app = App::new_with_review(prep.source, cfg, strings, prep.review)?;
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let res = run_loop(&mut terminal, &mut app);
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
drop(prep.cleanup);
res
}
struct Prep {
source: Box<dyn DiffSource>,
cleanup: Option<review::WorktreeGuard>,
review: Option<review_session::ReviewSession>,
}
fn prepare(cli: &Cli) -> Result<Prep> {
if !cli.paths.is_empty() {
if cli.paths.len() != 2 {
anyhow::bail!("file-pair mode expects exactly 2 paths");
}
return Ok(Prep {
source: Box::new(FilePair {
left: cli.paths[0].clone(),
right: cli.paths[1].clone(),
}),
cleanup: None,
review: None,
});
}
let cwd = std::env::current_dir()?;
if let Some(spec) = &cli.review {
let resolved: String = if spec.is_empty() {
let provider =
review::make_picker_provider().context("no review backend available")?;
match picker::pick_pr(provider.as_ref(), !cli.all)? {
Some(n) => n.to_string(),
None => {
eprintln!("lziff: cancelled.");
std::process::exit(0);
}
}
} else {
spec.clone()
};
let opened = review::open(&resolved)?;
return Ok(Prep {
source: opened.source,
cleanup: Some(opened.guard),
review: Some(opened.session),
});
}
if cli.staged {
return Ok(Prep {
source: Box::new(GitSource::staged(&cwd)?),
cleanup: None,
review: None,
});
}
if let Some(rev) = &cli.commit {
return Ok(Prep {
source: Box::new(GitSource::commit(&cwd, rev)?),
cleanup: None,
review: None,
});
}
if let Some(range) = &cli.range {
let (from, to) = range
.split_once("..")
.ok_or_else(|| anyhow::anyhow!("--range expects FROM..TO, got `{range}`"))?;
if from.is_empty() || to.is_empty() {
anyhow::bail!("--range needs both endpoints, got `{range}`");
}
return Ok(Prep {
source: Box::new(GitSource::range(&cwd, from, to)?),
cleanup: None,
review: None,
});
}
Ok(Prep {
source: Box::new(GitSource::working_tree(&cwd)?),
cleanup: None,
review: None,
})
}
fn run_loop<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
) -> Result<()> {
let tick = Duration::from_millis(app.config.behavior.tick_ms);
let mut last_tick = Instant::now();
loop {
terminal.draw(|f| {
ui::render(f, app);
ui::scrub_control_chars(f.buffer_mut());
})?;
let timeout = tick.saturating_sub(last_tick.elapsed());
if event::poll(timeout)? {
match event::read()? {
Event::Key(key) => {
if key.kind != crossterm::event::KeyEventKind::Press {
continue;
}
match keys::handle_key(app, key) {
keys::Action::Quit => return Ok(()),
keys::Action::Continue => {}
}
}
Event::Mouse(m) => keys::handle_mouse(app, m),
_ => {}
}
}
if last_tick.elapsed() >= tick {
app.poll_changes();
last_tick = Instant::now();
}
}
}