use std::path::Path;
use clap::{Parser, Subcommand};
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 root = match repo {
Ok(root) => root,
Err(err) => exit_without_repo(&err, &cli.path),
};
let review = Review::open_with_settings(&root, &loaded.config.diff_settings())?;
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::{
DisableFocusChange, DisableMouseCapture, EnableFocusChange, EnableMouseCapture,
};
let mut out = std::io::stdout();
let _ = if on {
crossterm::execute!(out, EnableMouseCapture, EnableFocusChange)
} else {
crossterm::execute!(out, DisableMouseCapture, DisableFocusChange)
};
}
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_stderr)]
fn exit_without_repo(err: &diffler_core::repo::RepoError, path: &str) -> ! {
let shown = std::fs::canonicalize(path)
.map_or_else(|_| path.to_owned(), |full| full.display().to_string());
match err {
diffler_core::repo::RepoError::NotFound(_) => {
eprintln!("diffler: no git repository at {shown} or any parent");
eprintln!(
"start one with `git init`, or point diffler at a repository: diffler <path>"
);
}
diffler_core::repo::RepoError::Bare(_) => {
eprintln!("diffler: {shown} is a bare repository, which has nothing to review");
eprintln!("open a clone with a working tree instead");
}
diffler_core::repo::RepoError::JjNotColocated(_) => {
eprintln!("diffler: {shown} is a jj repo without git colocation");
eprintln!("run `jj git colocation enable` there, then reopen diffler");
}
git @ diffler_core::repo::RepoError::Git(_) => eprintln!("diffler: {git}"),
}
std::process::exit(1)
}
#[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<()> {
if let Ok(picker) = ratatui_image::picker::Picker::from_query_stdio() {
app.image_picker = picker;
}
let (tx, mut rx) = mpsc::unbounded_channel();
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 = start_mcp(&mut app, &tx);
let mut needs_draw = true;
loop {
if needs_draw {
tokio::task::block_in_place(|| terminal.draw(|frame| ui::draw(frame, &mut app)))?;
needs_draw = false;
}
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);
needs_draw = true;
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);
if let Some(request) = app.pending_ci.take() {
dispatch_ci(&app, request, &tx);
continue;
}
let Some(event) = rx.recv().await else { break };
match tokio::task::block_in_place(|| app.handle(event)) {
Flow::Quit => break,
Flow::Continue => needs_draw = true,
Flow::Idle => {}
}
let mut quit = false;
while let Ok(event) = rx.try_recv() {
match tokio::task::block_in_place(|| app.handle(event)) {
Flow::Quit => {
quit = true;
break;
}
Flow::Continue => needs_draw = true,
Flow::Idle => {}
}
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, mcp.port);
}
drop(watcher);
Ok(())
}
fn start_mcp(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>) -> Option<mcp::McpHandle> {
if !app.config.mcp.enabled {
return None;
}
let handle = match mcp::spawn_mcp(tx.clone(), app.feedback_tx.subscribe(), app.config.mcp.port)
{
Ok(handle) => handle,
Err(err) => {
app.error(format!("mcp server failed to start: {err}"));
return None;
}
};
app.mcp_port = Some(handle.port);
if let Err(err) = mcp::write_endpoint(&app.review.repo_root, handle.port) {
app.error(format!("failed to write mcp endpoint file: {err}"));
}
Some(handle)
}
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 settings = app.config.diff_settings();
let against = app.against_rev().map(str::to_owned);
let tx = tx.clone();
tokio::task::spawn_blocking(move || {
let result = diffler_core::review::Review::compute_refresh(
&root,
&settings,
against.as_deref(),
None,
)
.map_err(|err| err.to_string());
let _ = tx.send(AppEvent::RefreshDone(Box::new(result)));
});
}
fn dispatch_file(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>) {
let Some(request) = app.pending_file.take() else {
return;
};
let tx = tx.clone();
let root = app.review.repo_root.clone();
let highlighter = std::sync::Arc::clone(&app.highlighter);
tokio::task::spawn_blocking(move || {
let result = diffler_core::review::Review::compute_file(&root, &request.path)
.map_err(|err| format!("{}: {err}", request.path))
.map(|snapshot| {
let highlights = highlighter.highlight(&snapshot.path, &snapshot.content);
app::file::FileView::new(
snapshot.path,
&snapshot.content,
highlights,
snapshot.blame,
request.blame,
)
});
let _ = tx.send(AppEvent::FileLoaded {
result: Box::new(result),
span: request.span,
token: request.token,
});
});
}
fn dispatch_declared(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>) {
let Some(request) = app.pending_declared.take() else {
return;
};
let tx = tx.clone();
let root = app.review.repo_root.clone();
tokio::task::spawn_blocking(move || {
let kinds = diffler_core::review::Review::compute_declared(&root, &request.paths)
.unwrap_or_default();
let _ = tx.send(AppEvent::DeclaredKinds {
kinds,
token: request.token,
});
});
}
fn dispatch_stats(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>) {
let Some(request) = app.pending_stats.take() else {
return;
};
let tx = tx.clone();
let root = app.review.repo_root.clone();
let untracked: Vec<String> = app
.review
.status
.untracked
.files
.iter()
.map(|file| file.path.clone())
.collect();
let rules = app.config.classify.rules();
tokio::task::spawn_blocking(move || {
let stats = diffler_core::stats::scan_repo(&root, &untracked, &rules).unwrap_or_default();
let _ = tx.send(AppEvent::RepoStats {
stats: Box::new(stats),
token: request.token,
});
});
}
fn dispatch_walkthrough(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>) {
let Some(request) = app.pending_walkthrough.take() else {
return;
};
let tx = tx.clone();
let root = app.review.repo_root.clone();
tokio::task::spawn_blocking(move || {
let read = diffler_core::review::Review::compute_walkthrough_files(
&root,
request.read_rev.as_deref(),
request.read_first,
&request.files,
);
let _ = tx.send(AppEvent::WalkthroughAnchors {
contents: read.contents,
pin_broken: read.pin_broken,
token: request.token,
});
});
}
fn dispatch_image(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>) {
let Some(request) = app.pending_image.take() else {
return;
};
let tx = tx.clone();
let root = app.review.repo_root.clone();
let picker = app.image_picker.clone();
tokio::task::spawn_blocking(move || {
let key = &request.key;
let sides = diffler_core::review::Review::compute_binary_sides(
&root,
&key.path,
&key.blobs,
key.deleted,
);
let preview = app::image::build_preview(&picker, &request, sides);
let _ = tx.send(AppEvent::ImagePreview {
token: request.token,
preview: Box::new(preview),
});
});
}
fn dispatch_workers(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>) {
dispatch_enrich(app, tx);
dispatch_image(app, tx);
dispatch_walkthrough(app, tx);
dispatch_file(app, tx);
dispatch_declared(app, tx);
dispatch_stats(app, tx);
dispatch_pr_posts(app, tx);
dispatch_refresh(app, tx);
dispatch_rediff(app, tx);
}
fn dispatch_rediff(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>) {
let Some(request) = app.start_rediff() else {
return;
};
let root = app.review.repo_root.clone();
let settings = app.config.diff_settings();
let tx = tx.clone();
tokio::task::spawn_blocking(move || {
let result = request.run(&root, &settings);
let _ = tx.send(AppEvent::RediffDone {
result: Box::new(result),
request,
});
});
}
fn provider_for(
remote: &diffler::app::CiRemote,
repo_root: &Path,
branch: Option<&str>,
yaml_cache: ci::YamlCache,
etags: ci::EtagCache,
) -> Box<dyn ci::ForgeProvider + Send> {
ci::build_provider(
&remote.detected,
repo_root,
branch,
remote.url.as_deref(),
yaml_cache,
etags,
)
}
fn dispatch_pr_posts(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>) {
if app.pending_pr_posts.is_empty() {
return;
}
let Some(remote) = app.ci_remotes().first().cloned() else {
app.drop_pending_pr_posts();
return;
};
let repo_root = app.review.repo_root.clone();
let branch = app.head.branch.clone();
let yaml_cache = app.ci_yaml_cache.clone();
let etags = app.ci_etags.clone();
for post in std::mem::take(&mut app.pending_pr_posts) {
let tx = tx.clone();
let repo_root = repo_root.clone();
let branch = branch.clone();
let yaml_cache = yaml_cache.clone();
let etags = etags.clone();
let remote = remote.clone();
tokio::spawn(async move {
let provider = provider_for(&remote, &repo_root, branch.as_deref(), yaml_cache, etags);
let result = match &post {
app::pr::PrPost::Review { review, .. } => {
provider.submit_pr_review(review).await.map(|()| None)
}
app::pr::PrPost::Reply {
number,
parent_remote_id,
body,
..
} => provider
.reply_pr_comment(*number, parent_remote_id, body)
.await
.map(Some),
app::pr::PrPost::Resolve {
number,
thread_id,
resolved,
..
} => provider
.resolve_pr_thread(*number, thread_id, *resolved)
.await
.map(|()| None),
app::pr::PrPost::Edit {
number,
remote_id,
body,
..
} => provider
.update_pr_comment(*number, remote_id, body)
.await
.map(|()| None),
app::pr::PrPost::Delete {
number, remote_id, ..
} => provider
.delete_pr_comment(*number, remote_id)
.await
.map(|()| None),
};
let _ = tx.send(AppEvent::PrPosted {
post: Box::new(post),
result: result.map_err(|err| err.to_string()),
});
});
}
}
fn dispatch_enrich(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>) {
for job in app.pending_enrich.drain(..) {
let tx = tx.clone();
let highlighter = std::sync::Arc::clone(&app.highlighter);
tokio::task::spawn_blocking(move || {
let outcome = app::enrich::run_enrich(&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 etags = app.ci_etags.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 = provider_for(
remote,
&repo_root,
branch.as_deref(),
yaml_cache.clone(),
etags.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 = provider_for(&remote, &repo_root, branch.as_deref(), yaml_cache, etags);
tokio::spawn(async move {
let _ = tx.send(run_ci_request(provider, request).await);
});
}
}
async fn run_ci_request(
provider: Box<dyn crate::ci::ForgeProvider + 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::CreatePr(new) => AppEvent::PrCreated(Box::new(
provider
.create_pr(&new)
.await
.map_err(|err| err.to_string()),
)),
CiRequest::Prs => match provider.list_prs().await {
Ok(prs) => AppEvent::CiPrs(prs),
Err(err) => AppEvent::CiPrsError(err.to_string()),
},
CiRequest::PrComments(number) => match provider.pr_comments(number).await {
Ok(comments) => AppEvent::PrComments {
number,
comments,
pr: provider.pr(number).await.ok(),
},
Err(err) => AppEvent::CiError(err.to_string()),
},
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)
.stdin(std::process::Stdio::null())
.envs(diffler::proc::NO_PROMPT)
.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}"),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_git_child_cannot_ask_the_user_anything() {
let argv = [
"sh",
"-c",
"printf %s \"$GIT_TERMINAL_PROMPT$GH_PROMPT_DISABLED\"",
]
.map(str::to_owned)
.to_vec();
let AppEvent::GitDone { ok, output, .. } = run_git("probe", &argv, Path::new(".")) else {
panic!("a git op answers with GitDone");
};
assert!(ok, "{output}");
assert_eq!(output, "01");
}
}