use std::io::{self, Read, Write};
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event};
use crossterm::execute;
use diffier::app::App;
use diffier::paths::Paths;
use diffier::render::{EditCard, LayoutOpts, ViewMode};
use diffier::session::{Pipeline, session_matches, session_order};
use diffier::spool::{self, MatchMode};
use diffier::ui;
use diffier::{hook, install};
const TICK: Duration = Duration::from_millis(100);
#[derive(Parser)]
#[command(
name = "diffier",
version,
about = "Live diff feed for Claude Code edits"
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[command(flatten)]
run: TuiArgs,
}
#[derive(clap::Args, Clone)]
struct RunArgs {
#[arg(long)]
cwd: Option<PathBuf>,
#[arg(long)]
cwd_only: bool,
#[arg(long)]
spool: Option<PathBuf>,
#[arg(long)]
snapshots: Option<PathBuf>,
#[arg(long, hide = true)]
no_delta: bool,
}
impl RunArgs {
fn match_mode(&self) -> MatchMode {
if self.cwd_only {
MatchMode::CwdOnly
} else {
MatchMode::Repo
}
}
}
#[derive(clap::Args, Clone)]
struct TuiArgs {
#[command(flatten)]
run: RunArgs,
#[arg(long)]
unified: bool,
}
#[derive(Subcommand)]
enum Command {
Run(TuiArgs),
Install,
Uninstall {
#[arg(long)]
purge: bool,
},
Dump {
#[command(flatten)]
run: RunArgs,
#[arg(long)]
session: Option<String>,
#[arg(long)]
side_by_side: bool,
#[arg(long)]
width: Option<u16>,
#[arg(long)]
no_wrap: bool,
},
Hook,
}
fn resolve(args: &RunArgs) -> Result<(Paths, PathBuf)> {
let mut paths = Paths::discover();
if let Some(s) = &args.spool {
paths.spool = s.clone();
}
if let Some(s) = &args.snapshots {
paths.snapshot_root = s.clone();
}
let cwd = match &args.cwd {
Some(c) => c.clone(),
None => std::env::current_dir().context("current directory")?,
};
Ok((paths, cwd))
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
None => run_tui(&cli.run),
Some(Command::Run(args)) => run_tui(&args),
Some(Command::Install) => {
let exe = std::env::current_exe().context("locating the diffier binary")?;
install::install(&Paths::discover(), &exe)
}
Some(Command::Uninstall { purge }) => install::uninstall(&Paths::discover(), purge),
Some(Command::Dump {
run,
session,
side_by_side,
width,
no_wrap,
}) => dump(&run, session.as_deref(), side_by_side, width, no_wrap),
Some(Command::Hook) => run_hook(),
}
}
fn run_hook() -> Result<()> {
let _ = std::panic::catch_unwind(|| {
let mut payload = Vec::new();
if io::stdin().lock().read_to_end(&mut payload).is_err() {
return;
}
if let Some(paths) = hook::paths_from_env() {
hook::run(&paths, &payload);
}
});
std::process::exit(0)
}
fn dump(
args: &RunArgs,
session: Option<&str>,
side_by_side: bool,
width: Option<u16>,
no_wrap: bool,
) -> Result<()> {
let (paths, cwd) = resolve(args)?;
if !paths.spool.exists() {
anyhow::bail!(
"spool not found: {} (run `diffier install` and make an edit)",
paths.spool.display()
);
}
let mut pipeline = Pipeline::new(cwd, paths.snapshot_root.clone(), args.match_mode());
let replay = spool::scan_replay(&paths.spool, pipeline.matcher_mut());
let inputs = pipeline.replay(&replay.events);
let tags = session_order(&inputs).len() > 1;
let opts = LayoutOpts {
mode: if side_by_side {
ViewMode::SideBySide
} else {
ViewMode::Unified
},
width: width.unwrap_or_else(terminal_width),
wrap: !no_wrap,
};
let mut out = io::stdout().lock();
for input in inputs {
if let Some(needle) = session
&& !input
.session_id
.as_deref()
.is_some_and(|id| session_matches(id, needle))
{
continue;
}
let card = EditCard::new(input, &opts);
writeln!(out, "== {}", card.header_text(tags))?;
for l in card.body_plain() {
writeln!(out, "{l}")?;
}
writeln!(out)?;
}
Ok(())
}
fn terminal_width() -> u16 {
crossterm::terminal::size().map(|(w, _)| w).unwrap_or(100)
}
fn run_tui(args: &TuiArgs) -> Result<()> {
let (paths, cwd) = resolve(&args.run)?;
let mode = if args.unified {
ViewMode::Unified
} else {
ViewMode::SideBySide
};
let mut app = App::new(cwd, &paths, mode, terminal_width(), args.run.match_mode());
let mut terminal = ratatui::init();
let _ = execute!(io::stdout(), EnableMouseCapture);
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = execute!(io::stdout(), DisableMouseCapture);
previous(info);
}));
let result = event_loop(&mut terminal, &mut app);
let _ = execute!(io::stdout(), DisableMouseCapture);
ratatui::restore();
let _ = std::panic::take_hook();
result
}
fn dispatch(app: &mut App, ev: Event) {
match ev {
Event::Key(k) if k.kind != event::KeyEventKind::Release => app.on_key(k),
Event::Mouse(m) => app.on_mouse(m),
Event::Resize(w, h) => app.on_resize(w, h),
_ => {}
}
}
fn event_loop(terminal: &mut ratatui::DefaultTerminal, app: &mut App) -> Result<()> {
loop {
terminal.draw(|f| ui::draw(f, app))?;
if event::poll(TICK)? {
dispatch(app, event::read()?);
while event::poll(Duration::ZERO)? {
dispatch(app, event::read()?);
}
}
app.tick();
if app.quit {
return Ok(());
}
}
}