mod app;
mod cache;
mod cli;
mod config;
mod diff;
mod event;
mod grouper;
mod highlight;
mod preview;
mod review;
mod signal;
mod theme;
mod ui;
use anyhow::Result;
use app::{App, Command, Message};
use clap::Parser;
use tokio::sync::mpsc;
fn spawn_review_batch(
cmd: Command,
tx: &mpsc::Sender<Message>,
app: &mut App,
) {
if let Command::SpawnReviewBatch(cmds) = cmd {
for cmd in cmds {
if let Command::SpawnReviewSection { backend, model, section, prompt, group_content_hash } = cmd {
let tx2 = tx.clone();
let handle = tokio::spawn(async move {
let result = crate::review::llm::invoke_review_section(
backend, &model, &prompt,
).await;
let _ = tx2.send(Message::ReviewSectionReady(group_content_hash, section, result)).await;
});
app.review_handles.insert((group_content_hash, section), handle);
}
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
signal::remove_pid_file();
ratatui::restore();
original_hook(info);
}));
let log_path = signal::log_file_path();
if let Some(parent) = log_path.parent() {
if !parent.exists() {
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
let _ = std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(parent);
}
#[cfg(not(unix))]
{
let _ = std::fs::create_dir_all(parent);
}
}
}
let log_file = {
let mut opts = std::fs::OpenOptions::new();
opts.create(true).write(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
opts.open(&log_path)?
};
tracing_subscriber::fmt()
.with_env_filter("semantic_diff=debug")
.with_writer(log_file)
.with_ansi(false)
.init();
tracing::info!("semantic-diff starting");
let cli = cli::Cli::parse();
let git_diff_args = cli.git_diff_args();
tracing::info!(?git_diff_args, "Git diff args");
let mut config = config::load();
if cli.theme.is_some() {
config.theme_mode = cli.theme_mode();
}
tracing::info!(?config, "Loaded config");
let output = std::process::Command::new("git")
.args(&git_diff_args)
.output()?;
let raw_git_diff = String::from_utf8_lossy(&output.stdout);
let (diff_data, raw_diff) = diff::parse_with_untracked(&raw_git_diff);
if diff_data.files.is_empty() && diff_data.binary_files.is_empty() {
eprintln!("No changes detected");
return Ok(());
}
tracing::info!(
files = diff_data.files.len(),
binary = diff_data.binary_files.len(),
"Parsed diff"
);
signal::write_pid_file()?;
let (tx, mut rx) = mpsc::channel::<Message>(32);
let mut app = App::new(diff_data, &config, git_diff_args);
app.event_tx = Some(tx.clone());
tokio::spawn(event::event_loop(tx.clone()));
let diff_hash = cache::diff_hash(&raw_diff);
if let Some(cached_groups) = cache::load(diff_hash) {
let mut cached_groups = cached_groups;
grouper::normalize_hunk_indices(&mut cached_groups, &app.diff_data);
app.semantic_groups = Some(cached_groups);
app.grouping_status = grouper::GroupingStatus::Done;
tracing::info!("Using cached grouping");
app.previous_head = cache::get_head_commit();
app.previous_file_hashes = grouper::compute_all_file_hashes(&app.diff_data);
if let Some(cmd) = app.spawn_all_reviews() {
spawn_review_batch(cmd, &tx, &mut app);
}
} else if let Some(backend) = app.llm_backend {
let current_head = cache::get_head_commit();
let mut used_incremental = false;
if let Some(ref head) = current_head {
if let Some((prev_groups, prev_file_hashes)) = cache::load_incremental(head) {
let new_hashes = grouper::compute_all_file_hashes(&app.diff_data);
let delta = grouper::compute_diff_delta(&new_hashes, &prev_file_hashes);
if !delta.has_changes() {
let mut groups = prev_groups;
grouper::normalize_hunk_indices(&mut groups, &app.diff_data);
app.semantic_groups = Some(groups);
app.grouping_status = grouper::GroupingStatus::Done;
app.previous_head = Some(head.clone());
app.previous_file_hashes = new_hashes;
tracing::info!("Incremental cache: no changes since last save");
if let Some(cmd) = app.spawn_all_reviews() {
spawn_review_batch(cmd, &tx, &mut app);
}
used_incremental = true;
} else if delta.is_only_removals() {
let mut groups = prev_groups;
grouper::remove_files_from_groups(&mut groups, &delta.removed_files);
grouper::normalize_hunk_indices(&mut groups, &app.diff_data);
app.semantic_groups = Some(groups);
app.grouping_status = grouper::GroupingStatus::Done;
app.previous_head = Some(head.clone());
app.previous_file_hashes = new_hashes.clone();
cache::save_with_state(diff_hash, app.semantic_groups.as_ref().unwrap(), Some(head), &new_hashes);
tracing::info!("Incremental cache: pruned removed files");
if let Some(cmd) = app.spawn_all_reviews() {
spawn_review_batch(cmd, &tx, &mut app);
}
used_incremental = true;
} else {
let summaries = grouper::incremental_hunk_summaries(&app.diff_data, &delta, &prev_groups);
let model = app.llm_model.clone();
let head_clone = head.clone();
let tx2 = tx.clone();
tracing::info!(
new = delta.new_files.len(),
modified = delta.modified_files.len(),
removed = delta.removed_files.len(),
"Incremental grouping on startup"
);
app.semantic_groups = Some(prev_groups);
app.previous_head = Some(head.clone());
app.previous_file_hashes = prev_file_hashes;
let handle = tokio::spawn(async move {
match grouper::llm::request_incremental_grouping(backend, &model, &summaries).await {
Ok(groups) => {
let _ = tx2.send(Message::IncrementalGroupingComplete(
groups, delta, new_hashes, diff_hash, head_clone,
)).await;
}
Err(e) => {
let _ = tx2.send(Message::GroupingFailed(e.to_string())).await;
}
}
});
app.grouping_handle = Some(handle);
app.grouping_status = grouper::GroupingStatus::Loading;
used_incremental = true;
}
}
}
if !used_incremental {
let summaries = grouper::hunk_summaries(&app.diff_data);
let model = app.llm_model.clone();
let tx2 = tx.clone();
let handle = tokio::spawn(async move {
match grouper::llm::request_grouping_with_timeout(backend, &model, &summaries).await {
Ok(groups) => {
let _ = tx2.send(Message::GroupingComplete(groups, diff_hash)).await;
}
Err(e) => {
let _ = tx2.send(Message::GroupingFailed(e.to_string())).await;
}
}
});
app.grouping_handle = Some(handle);
app.grouping_status = grouper::GroupingStatus::Loading;
}
}
let mut terminal = ratatui::init();
let mut had_images_last_frame = false;
loop {
let mut pending_images = Vec::new();
terminal.draw(|f| {
app.ui_state.viewport_height = f.area().height.saturating_sub(1);
pending_images = app.view(f);
})?;
let has_images = !pending_images.is_empty();
if let preview::mermaid::ImageSupport::Supported(protocol) = &app.image_support {
if has_images {
ui::preview_view::flush_images(&pending_images, *protocol);
} else if had_images_last_frame {
ui::preview_view::clear_stale_images(*protocol, &mut terminal);
terminal.draw(|f| {
app.ui_state.viewport_height = f.area().height.saturating_sub(1);
app.view(f);
})?;
}
}
had_images_last_frame = has_images;
if let Some(msg) = rx.recv().await {
if let Some(cmd) = app.update(msg) {
match cmd {
Command::SpawnDiffParse { git_diff_args } => {
let tx2 = tx.clone();
tokio::spawn(async move {
let output = tokio::process::Command::new("git")
.args(&git_diff_args)
.output()
.await;
if let Ok(output) = output {
let raw_git = String::from_utf8_lossy(&output.stdout).to_string();
let untracked = crate::diff::untracked::discover_untracked_files_async().await;
let (data, combined) = crate::diff::parse_with_untracked_paths(&raw_git, &untracked);
let _ = tx2.send(Message::DiffParsed(data, combined)).await;
}
});
}
Command::SpawnGrouping { backend, model, summaries, diff_hash, .. } => {
let tx2 = tx.clone();
let handle = tokio::spawn(async move {
match crate::grouper::llm::request_grouping_with_timeout(
backend,
&model,
&summaries,
)
.await
{
Ok(groups) => {
let _ = tx2.send(Message::GroupingComplete(groups, diff_hash)).await;
}
Err(e) => {
let _ =
tx2.send(Message::GroupingFailed(e.to_string())).await;
}
}
});
app.grouping_handle = Some(handle);
}
Command::SpawnIncrementalGrouping {
backend,
model,
summaries,
diff_hash,
head_commit,
file_hashes,
delta,
} => {
let tx2 = tx.clone();
let handle = tokio::spawn(async move {
match crate::grouper::llm::request_incremental_grouping(
backend,
&model,
&summaries,
)
.await
{
Ok(groups) => {
let _ = tx2
.send(Message::IncrementalGroupingComplete(
groups,
delta,
file_hashes,
diff_hash,
head_commit,
))
.await;
}
Err(e) => {
let _ =
tx2.send(Message::GroupingFailed(e.to_string())).await;
}
}
});
app.grouping_handle = Some(handle);
}
Command::SpawnReviewBatch(_) => {
spawn_review_batch(cmd, &tx, &mut app);
}
Command::SpawnReviewSection { .. } => {
}
Command::CancelReview(hash) => {
let keys: Vec<_> = app.review_handles.keys()
.filter(|(h, _)| *h == hash)
.cloned()
.collect();
for key in keys {
if let Some(handle) = app.review_handles.remove(&key) {
handle.abort();
}
}
}
Command::Quit => break,
}
}
} else {
break; }
}
signal::remove_pid_file();
ratatui::restore();
Ok(())
}