use std::path::Path;
use clap::{Parser, Subcommand};
use diffler::app::blast::LspPool;
use diffler::app::{self, App, CiRequest, Flow};
use diffler::event::AppEvent;
use diffler::{ci, clipboard, config, editor, event, mcp, ui, watch};
use diffler_core::review::Review;
use ratatui::DefaultTerminal;
use tokio::sync::mpsc;
#[derive(Parser, Debug)]
#[command(version, about)]
struct Cli {
#[arg(default_value = ".")]
path: String,
#[arg(long, global = true)]
port: Option<u16>,
#[arg(long, global = true)]
no_mcp: bool,
#[arg(long, global = true)]
theme: Option<String>,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand, Debug)]
enum Command {
Config {
#[arg(long)]
dump: bool,
},
}
#[tokio::main]
async fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
let cli = Cli::parse();
let repo = diffler_core::repo::discover(std::path::Path::new(&cli.path));
let overrides = config::CliOverrides {
port: cli.port,
mcp_enabled: cli.no_mcp.then_some(false),
theme: cli.theme.clone(),
};
let loaded = config::load(repo.as_deref().ok(), &overrides)?;
match cli.command {
Some(Command::Config { dump: true }) => print_config_dump(&loaded),
Some(Command::Config { dump: false }) => Err(color_eyre::eyre::eyre!(
"nothing to do: try `diffler config --dump`"
)),
None => {
let review = Review::open_with_context(&repo?, loaded.config.ui.context_lines)?;
let app = App::new(review, loaded);
let terminal = ratatui::init();
set_mouse_capture(true);
install_mouse_panic_hook();
let result = run(terminal, app).await;
set_mouse_capture(false);
ratatui::restore();
result
}
}
}
fn set_mouse_capture(on: bool) {
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
let mut out = std::io::stdout();
let _ = if on {
crossterm::execute!(out, EnableMouseCapture)
} else {
crossterm::execute!(out, DisableMouseCapture)
};
}
fn install_mouse_panic_hook() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
set_mouse_capture(false);
previous(info);
}));
}
#[allow(clippy::print_stdout)]
fn print_config_dump(loaded: &config::LoadedConfig) -> color_eyre::Result<()> {
print!("{}", config::render_dump(loaded)?);
Ok(())
}
async fn run(mut terminal: DefaultTerminal, mut app: App) -> color_eyre::Result<()> {
let (tx, mut rx) = mpsc::unbounded_channel();
let lsp_pool = LspPool::default();
let mut events = event::spawn_event_loop(tx.clone());
let git_dir = app
.review
.vcs
.git_dir()
.unwrap_or_else(|_| app.review.repo_root.join(".git"));
let watcher = watch::spawn_watcher(&app.review.repo_root, &git_dir, tx.clone()).ok();
if let Some(handle) = &watcher {
app.watcher_healthy = Some(handle.healthy.clone());
}
let mcp = if app.config.mcp.enabled {
match mcp::spawn_mcp(tx.clone(), app.feedback_tx.subscribe(), app.config.mcp.port) {
Ok(handle) => {
app.mcp_port = Some(handle.port);
let _ = mcp::write_endpoint(&app.review.repo_root, handle.port);
if app.message.is_none() {
app.info(mcp::connect_hint(handle.port));
}
Some(handle)
}
Err(err) => {
app.error(format!("mcp server failed to start: {err}"));
None
}
}
} else {
None
};
loop {
terminal.draw(|frame| ui::draw(frame, &mut app))?;
if let Some(text) = app.pending_clipboard.take() {
use std::io::Write as _;
let mut out = std::io::stdout();
out.write_all(clipboard::osc52(&text).as_bytes())?;
out.flush()?;
tokio::task::spawn_blocking(move || clipboard::native_copy(&text));
}
if let Some(request) = app.pending_editor.take() {
events.abort();
let _ = (&mut events).await;
set_mouse_capture(false);
ratatui::restore();
let editor::EditorRequest { cmd, purpose } = request;
let outcome = tokio::task::spawn_blocking(move || editor::run(&cmd))
.await
.map_err(|err| err.to_string())
.and_then(|result| result.map_err(|err| err.to_string()));
terminal = ratatui::init();
set_mouse_capture(true);
terminal.clear()?;
events = event::spawn_event_loop(tx.clone());
app.editor_finished(purpose, outcome);
continue;
}
if let Some(app::GitOp { label, argv }) = app.pending_git.take() {
let repo_root = app.review.repo_root.clone();
let tx = tx.clone();
tokio::task::spawn_blocking(move || {
let _ = tx.send(run_git(&label, &argv, &repo_root));
});
continue;
}
dispatch_workers(&mut app, &tx, &lsp_pool);
if let Some(request) = app.pending_ci.take() {
dispatch_ci(&app, request, &tx);
continue;
}
let Some(event) = rx.recv().await else { break };
if app.handle(event) == Flow::Quit {
break;
}
let mut quit = false;
while let Ok(event) = rx.try_recv() {
if app.handle(event) == Flow::Quit {
quit = true;
break;
}
if app.pending_editor.is_some()
|| app.pending_git.is_some()
|| app.pending_ci.is_some()
|| app.pending_clipboard.is_some()
{
break;
}
}
if quit {
break;
}
}
events.abort();
if let Some(mcp) = mcp {
mcp.handle.abort();
mcp::clear_endpoint(&app.review.repo_root);
}
drop(watcher);
Ok(())
}
fn dispatch_refresh(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>) {
if app.refresh_state != app::RefreshState::Queued {
return;
}
app.refresh_state = app::RefreshState::Running;
let root = app.review.repo_root.clone();
let context = app.config.ui.context_lines;
let tx = tx.clone();
tokio::task::spawn_blocking(move || {
let result = diffler_core::review::Review::compute_refresh(&root, context)
.map_err(|err| err.to_string());
let _ = tx.send(AppEvent::RefreshDone(Box::new(result)));
});
}
fn dispatch_workers(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>, lsp_pool: &LspPool) {
dispatch_enrich(app, tx);
dispatch_blast(app, tx, lsp_pool);
dispatch_chain(app, tx, lsp_pool);
dispatch_refresh(app, tx);
}
fn dispatch_chain(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>, pool: &LspPool) {
let Some(job) = app.pending_chain.take() else {
return;
};
let tx = tx.clone();
let pool = pool.clone();
let root = app.review.repo_root.clone();
tokio::spawn(async move {
let outcome = app::blast::chain_calls(&pool, &root, &job).await;
let _ = tx.send(AppEvent::Chain(Box::new(outcome)));
});
}
fn dispatch_blast(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>, pool: &LspPool) {
for job in app.pending_blast.drain(..) {
let tx = tx.clone();
let pool = pool.clone();
let root = app.review.repo_root.clone();
tokio::spawn(async move {
let outcome = app::blast::blast_refs(&pool, &root, job).await;
let _ = tx.send(AppEvent::Blast(Box::new(outcome)));
});
}
}
fn dispatch_enrich(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>) {
for job in app.pending_enrich.drain(..) {
let tx = tx.clone();
tokio::task::spawn_blocking(move || {
let outcome = app::enrich::run_enrich(ui::diff::highlighter(), job);
let _ = tx.send(AppEvent::Enriched(Box::new(outcome)));
});
}
}
fn dispatch_ci(app: &App, request: CiRequest, tx: &mpsc::UnboundedSender<AppEvent>) {
let remotes = app.ci_remotes();
if remotes.is_empty() {
return;
}
let repo_root = app.review.repo_root.clone();
let branch = app.head.branch.clone();
let yaml_cache = app.ci_yaml_cache.clone();
let tx = tx.clone();
if matches!(request, CiRequest::Runs) {
let multi = remotes.len() > 1;
tokio::spawn(async move {
let mut all = Vec::new();
for remote in &remotes {
let provider = ci::build_provider(
&remote.detected,
&repo_root,
branch.as_deref(),
remote.url.as_deref(),
yaml_cache.clone(),
);
if let Ok(mut runs) = provider.list_runs(30).await {
if multi {
for run in &mut runs {
run.remote = Some(remote.name.clone());
}
}
all.extend(runs);
}
}
all.sort_by_key(|run| std::cmp::Reverse(run.created));
let _ = tx.send(AppEvent::CiRuns(all));
});
return;
}
let remote = match &request {
CiRequest::Detail(_) | CiRequest::Extras(_) | CiRequest::Log { .. } => {
app.ci_remote_for_open_run()
}
_ => remotes.first().cloned(),
};
if let Some(remote) = remote {
let provider = ci::build_provider(
&remote.detected,
&repo_root,
branch.as_deref(),
remote.url.as_deref(),
yaml_cache,
);
tokio::spawn(async move {
let _ = tx.send(run_ci_request(provider, request).await);
});
}
}
async fn run_ci_request(
provider: Box<dyn crate::ci::CiProvider + Send>,
request: CiRequest,
) -> AppEvent {
match request {
CiRequest::Runs => match provider.list_runs(30).await {
Ok(runs) => AppEvent::CiRuns(runs),
Err(err) => AppEvent::CiError(err.to_string()),
},
CiRequest::Pr => AppEvent::CiPr(provider.current_pr().await.unwrap_or(None)),
CiRequest::Detail(id) => match provider.run_detail(&id).await {
Ok(detail) => AppEvent::CiRunDetail(detail),
Err(err) => AppEvent::CiError(err.to_string()),
},
CiRequest::Extras(id) => match provider.run_extras(&id).await {
Ok(extras) => AppEvent::CiExtras(extras),
Err(err) => AppEvent::CiError(err.to_string()),
},
CiRequest::Log { run, job, offset } => match provider.job_log(&run, &job, offset).await {
Ok(chunk) => AppEvent::CiLog {
text: chunk.text,
steps: chunk.steps,
next_offset: chunk.next_offset,
done: chunk.done,
},
Err(err) => AppEvent::CiError(err.to_string()),
},
}
}
fn run_git(label: &str, argv: &[String], repo_root: &Path) -> AppEvent {
let Some((program, rest)) = argv.split_first() else {
return AppEvent::GitDone {
label: label.to_owned(),
ok: false,
output: "empty command".to_owned(),
};
};
match std::process::Command::new(program)
.args(rest)
.current_dir(repo_root)
.output()
{
Ok(out) => {
let mut output = String::from_utf8_lossy(&out.stderr).into_owned();
output.push_str(&String::from_utf8_lossy(&out.stdout));
AppEvent::GitDone {
label: label.to_owned(),
ok: out.status.success(),
output,
}
}
Err(err) => AppEvent::GitDone {
label: label.to_owned(),
ok: false,
output: format!("cannot run {program}: {err}"),
},
}
}