pub mod keys;
pub mod panels;
pub mod types;
pub mod ui;
pub mod widgets;
use std::io::stdout;
use std::time::Duration;
use anyhow::Result;
use crossterm::event::{self, Event, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::prelude::*;
use tokio::sync::mpsc;
use crate::checks::{CheckEvent, CheckResult, CheckStatus as CrateCheckStatus};
use crate::{App, Config};
use types::{TuiEvent, TuiState};
pub async fn run_tui(config: Config) -> Result<()> {
enable_raw_mode()?;
let mut out = stdout();
if let Err(err) = execute!(out, EnterAlternateScreen) {
let _ = disable_raw_mode();
return Err(err.into());
}
let backend = CrosstermBackend::new(out);
let mut terminal = match Terminal::new(backend) {
Ok(terminal) => terminal,
Err(err) => {
let _ = disable_raw_mode();
let mut rollback_out = stdout();
let _ = execute!(rollback_out, LeaveAlternateScreen);
return Err(err.into());
}
};
if let Err(err) = terminal.hide_cursor() {
let _ = cleanup_terminal(&mut terminal);
return Err(err.into());
}
let run_result = async {
let mut state = TuiState::new(config);
initialize_state(&mut state)?;
let (tx, mut rx) = mpsc::unbounded_channel::<TuiEvent>();
run_event_loop(&mut terminal, &mut state, &tx, &mut rx).await
}
.await;
let cleanup_result = cleanup_terminal(&mut terminal);
match (run_result, cleanup_result) {
(Ok(()), Ok(())) => Ok(()),
(Err(run_err), Ok(())) => Err(run_err),
(Ok(()), Err(cleanup_err)) => Err(cleanup_err),
(Err(run_err), Err(cleanup_err)) => Err(anyhow::anyhow!(
"{run_err}; terminal cleanup failed: {cleanup_err}"
)),
}
}
fn cleanup_terminal(terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>) -> Result<()> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
async fn run_event_loop<B: Backend>(
terminal: &mut Terminal<B>,
state: &mut TuiState,
tx: &mpsc::UnboundedSender<TuiEvent>,
rx: &mut mpsc::UnboundedReceiver<TuiEvent>,
) -> Result<()>
where
B::Error: Send + Sync + 'static,
{
let tick_rate = Duration::from_millis(100);
loop {
terminal.clear()?;
terminal.draw(|f| ui::draw(f, state))?;
if state.should_quit {
break;
}
if event::poll(tick_rate)?
&& let Event::Key(key) = event::read()?
{
if key.kind == KeyEventKind::Release {
continue;
}
keys::handle_key(state, key, tx).await?;
}
while let Ok(evt) = rx.try_recv() {
handle_tui_event(state, evt);
}
if state.running {
state.update_message();
}
}
Ok(())
}
fn handle_tui_event(state: &mut TuiState, event: TuiEvent) {
match event {
TuiEvent::Tick => {
state.update_message();
}
TuiEvent::Key(_) => {
}
TuiEvent::CheckStarted { name } => {
state.update_check(&name, types::CheckLifecycle::Running);
}
TuiEvent::CheckCompleted { result } => {
state.set_check_result(&result);
}
TuiEvent::DiffsReady { diffs } => {
state.set_diffs(&diffs);
}
TuiEvent::HeuristicsReady { result } => {
state.set_heuristics(&result);
}
TuiEvent::ArtifactsReady { dir } => {
state.set_artifacts(&dir);
}
TuiEvent::AnalysisComplete { report } => {
state.running = false;
state.report = Some(report);
state.message =
"Analysis complete! [1-6] browse panels [4] artifacts [q]uit".to_string();
}
TuiEvent::Error { message } => {
state.running = false;
state.message = format!("Error: {}", message);
}
}
}
fn map_check_event(event: CheckEvent) -> TuiEvent {
match event {
CheckEvent::Started { name } => TuiEvent::CheckStarted { name },
CheckEvent::Completed { result } => TuiEvent::CheckCompleted { result },
CheckEvent::Skipped { name } => TuiEvent::CheckCompleted {
result: Box::new(CheckResult {
name,
status: CrateCheckStatus::Skipped,
duration: Duration::ZERO,
output: "Skipped in current context.".to_string(),
cached: false,
provenance: None,
}),
},
}
}
fn initialize_state(state: &mut TuiState) -> Result<()> {
let repo = crate::git::Repository::open(&state.config.repo_root)?;
repo.prepare_refs(&state.config)?;
if let Ok(branch_list) = repo.list_branches() {
state.branch_selector.local_branches =
branch_list.local.iter().map(|b| b.name.clone()).collect();
state.branch_selector.remote_branches =
branch_list.remote.iter().map(|b| b.name.clone()).collect();
state.branch_selector.current_branch = branch_list.current.clone();
}
if let Ok(target) = repo.resolve_target(&state.config) {
state.target_branch = target.name;
}
if let Ok(bases) = repo.resolve_bases(&state.config) {
state.base_branches = bases.iter().map(|b| b.name.clone()).collect();
}
let check_names = state.config.profile.get_check_names();
state.init_checks(&check_names);
state.message = "[b]ranch wizard [r]un [1-6]panel [?]help [q]uit".to_string();
Ok(())
}
pub async fn run_tui_state(config: Config, repo_state: crate::state::RepoState) -> Result<()> {
enable_raw_mode()?;
let mut out = stdout();
if let Err(err) = execute!(out, EnterAlternateScreen) {
let _ = disable_raw_mode();
return Err(err.into());
}
let backend = CrosstermBackend::new(out);
let mut terminal = match Terminal::new(backend) {
Ok(terminal) => terminal,
Err(err) => {
let _ = disable_raw_mode();
let mut rollback_out = stdout();
let _ = execute!(rollback_out, LeaveAlternateScreen);
return Err(err.into());
}
};
if let Err(err) = terminal.hide_cursor() {
let _ = cleanup_terminal(&mut terminal);
return Err(err.into());
}
let run_result = async {
let mut state = TuiState::new_state_view(config, repo_state);
let (tx, mut rx) = mpsc::unbounded_channel::<TuiEvent>();
run_event_loop(&mut terminal, &mut state, &tx, &mut rx).await
}
.await;
let cleanup_result = cleanup_terminal(&mut terminal);
match (run_result, cleanup_result) {
(Ok(()), Ok(())) => Ok(()),
(Err(run_err), Ok(())) => Err(run_err),
(Ok(()), Err(cleanup_err)) => Err(cleanup_err),
(Err(run_err), Err(cleanup_err)) => Err(anyhow::anyhow!(
"{run_err}; terminal cleanup failed: {cleanup_err}"
)),
}
}
pub async fn run_analysis(config: Config, tx: mpsc::UnboundedSender<TuiEvent>) -> Result<()> {
let t_start = std::time::Instant::now();
let (config, diffs, target, bases, target_snap, base_snap) = {
let app = App::from_config(config)?;
app.repo.prepare_refs(&app.config)?;
let target = app.repo.resolve_target(&app.config)?;
let bases = app.repo.resolve_bases(&app.config)?;
let diffs = app.repo.generate_diffs(&target, &bases)?;
let target_snap = if app.config.remote_mode || app.config.remote_only {
app.repo.create_snapshot(&target.commit_id).ok()
} else {
None
};
let base_snap = if app.config.remote_mode || app.config.remote_only {
bases
.first()
.and_then(|b| app.repo.create_snapshot(&b.commit_id).ok())
} else {
None
};
let config = app.config.clone();
(config, diffs, target, bases, target_snap, base_snap)
};
let _ = tx.send(TuiEvent::DiffsReady {
diffs: diffs.clone(),
});
let tx_checks = tx.clone();
let (check_results, skipped_checks) =
crate::checks::run_all_with_events(&config, move |event| {
let tx = tx_checks.clone();
let _ = tx.send(map_check_event(event));
})
.await?;
let heuristics = if let Some(ref snap) = target_snap {
let analysis_root = snap.path.clone();
let mut result = crate::heuristics::run_all(&config, Some(analysis_root.as_path())).await?;
if let Some(ref base_snap) = base_snap
&& let Ok(base_result) =
crate::heuristics::run_all(&config, Some(&base_snap.path)).await
{
let base_sha = bases.first().map(|b| b.commit_id.as_str()).unwrap_or("");
result.regression = crate::heuristics::compute_delta_checked(
&base_result,
&result,
base_sha,
&target.commit_id,
);
}
result
} else {
crate::heuristics::run_all(&config, None).await?
};
let _ = tx.send(TuiEvent::HeuristicsReady {
result: heuristics.clone(),
});
let artifacts_dir = crate::artifacts::generate(crate::artifacts::GenerateInput {
config: &config,
diffs: &diffs,
checks: &check_results,
heuristics: Some(&heuristics),
resolved_target: &target,
resolved_bases: &bases,
run_start: t_start,
skipped_checks,
})?;
let _ = tx.send(TuiEvent::ArtifactsReady {
dir: artifacts_dir.clone(),
});
let report = crate::output::Report {
target: target.name,
bases: bases.iter().map(|b| b.name.clone()).collect(),
diffs,
checks: check_results,
heuristics: Some(heuristics),
artifacts_dir,
duration: t_start.elapsed(),
unchanged: false,
};
let _ = tx.send(TuiEvent::AnalysisComplete { report });
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::test_config;
use crate::tui::types::CheckLifecycle;
fn default_config() -> Config {
test_config()
}
#[test]
fn skipped_check_events_map_to_skipped_results() {
let event = map_check_event(CheckEvent::Skipped {
name: "TypeScript".to_string(),
});
match event {
TuiEvent::CheckCompleted { result } => {
assert_eq!(result.name, "TypeScript");
assert_eq!(result.status, CrateCheckStatus::Skipped);
assert_eq!(result.duration, Duration::ZERO);
}
other => panic!("expected skipped check to map to CheckCompleted, got {other:?}"),
}
}
#[test]
fn skipped_check_events_do_not_leave_entries_running() {
let mut state = TuiState::new(default_config());
state.init_checks(&["TypeScript"]);
handle_tui_event(
&mut state,
map_check_event(CheckEvent::Skipped {
name: "TypeScript".to_string(),
}),
);
assert_eq!(
state.checks_state.entries[0].status,
CheckLifecycle::Skipped
);
}
}