mod render;
use crate::cli::Cli;
use crate::diff::filter_file_diffs;
use crate::diff::types::FileDiff;
use crate::parse;
use crate::pipeline::{self, DirectorySource, VcsSource};
use crate::vcs;
use anyhow::{Context, Result};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
use ratatui::DefaultTerminal;
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::Duration;
enum AppEvent {
FileChanged,
RegistryReady(crate::parse::shared::SharedExampleRegistry),
Tick,
}
#[allow(clippy::struct_excessive_bools)]
struct AppState {
file_diffs: Vec<FileDiff>,
scroll: usize,
section_offsets: Vec<usize>,
changed_only: bool,
filter: Option<String>,
quit: bool,
needs_redraw: bool,
max_scroll: usize,
shared_registry: Option<crate::parse::shared::SharedExampleRegistry>,
}
enum WatchMode<'a> {
Vcs {
vcs: &'a dyn vcs::Vcs,
merge_base: String,
head_rev: String,
},
Directory {
base: PathBuf,
head: PathBuf,
},
}
impl WatchMode<'_> {
fn compute_diffs_fast(&self, cli: &Cli) -> Result<Vec<FileDiff>> {
self.with_source(|source| pipeline::diff_files_fast(source, cli))
}
fn compute_diffs_with_registry(
&self,
cli: &Cli,
registry: &crate::parse::shared::SharedExampleRegistry,
) -> Result<Vec<FileDiff>> {
self.with_source(|source| pipeline::diff_files_with_registry(source, cli, registry))
}
fn needs_shared_scan(&self, cli: &Cli) -> bool {
self.with_source(|source| {
let paths = source.list_files().unwrap_or_default();
Ok(pipeline::changed_files_need_shared_scan(&paths, source, cli))
})
.unwrap_or(false)
}
fn build_registry(&self, cli: &Cli) -> crate::parse::shared::SharedExampleRegistry {
self.with_source(|source| {
let all_paths = source.list_files().unwrap_or_default();
let shared_paths = source.list_shared_files_all();
let all_scannable: Vec<String> = {
let mut set: std::collections::BTreeSet<String> = all_paths.into_iter().collect();
set.extend(shared_paths);
set.into_iter().collect()
};
Ok(pipeline::build_shared_registry(source, &all_scannable, cli, |s, path| s.read_head(path)))
})
.unwrap_or_default()
}
fn with_source<T>(&self, f: impl FnOnce(&dyn pipeline::FileSource) -> Result<T>) -> Result<T> {
match self {
WatchMode::Vcs { vcs, merge_base, head_rev } => {
let changed = vcs.changed_files(merge_base, head_rev)?;
let test_files: Vec<PathBuf> = changed
.into_iter()
.filter(|f| !parse::registry::frameworks_for_file(f).is_empty())
.collect();
let source = VcsSource {
vcs: *vcs,
files: test_files,
merge_base: merge_base.clone(),
head_rev: head_rev.clone(),
};
f(&source)
}
WatchMode::Directory { base, head } => {
let source = DirectorySource {
base: base.clone(),
head: head.clone(),
};
f(&source)
}
}
}
fn watch_paths(&self) -> Vec<PathBuf> {
match self {
WatchMode::Vcs { .. } => {
std::env::current_dir().map(|c| vec![c]).unwrap_or_default()
}
WatchMode::Directory { base, head } => {
let mut paths = vec![head.clone()];
if base != head {
paths.push(base.clone());
}
paths
}
}
}
}
pub fn run_watch(cli: &Cli) -> Result<()> {
match (&cli.base_dir, &cli.head_dir) {
(Some(base), Some(head)) => run_watch_directory(base, head, cli),
(Some(_), None) | (None, Some(_)) => {
anyhow::bail!("--base-dir and --head-dir must be used together")
}
(None, None) => run_watch_vcs(cli),
}
}
fn run_watch_vcs(cli: &Cli) -> Result<()> {
let cwd = std::env::current_dir().context("cannot determine working directory")?;
let vcs = crate::vcs::detect(&cwd)?;
let base_rev = cli.base.clone().unwrap_or_else(|| vcs.default_base_rev());
let head_rev = cli.head.clone().unwrap_or_else(|| vcs.default_head_rev().to_string());
let merge_base = vcs.merge_base(&base_rev, &head_rev)
.unwrap_or_else(|_| base_rev.clone());
let mode = WatchMode::Vcs {
vcs: vcs.as_ref(),
merge_base,
head_rev,
};
run_watch_loop(&mode, cli)
}
fn run_watch_directory(base: &str, head: &str, cli: &Cli) -> Result<()> {
let mode = WatchMode::Directory {
base: PathBuf::from(base),
head: PathBuf::from(head),
};
run_watch_loop(&mode, cli)
}
fn run_watch_loop(mode: &WatchMode<'_>, cli: &Cli) -> Result<()> {
let file_diffs = mode.compute_diffs_fast(cli)?;
let mut state = AppState {
file_diffs,
scroll: 0,
section_offsets: vec![],
max_scroll: 0,
changed_only: cli.changed_only,
filter: cli.filter.clone(),
quit: false,
needs_redraw: true,
shared_registry: None,
};
let (tx, rx) = mpsc::channel();
let fs_tx = tx.clone();
let _debouncer = setup_watcher(mode.watch_paths(), fs_tx)?;
let tick_tx = tx.clone();
std::thread::spawn(move || loop {
std::thread::sleep(Duration::from_secs(5));
if tick_tx.send(AppEvent::Tick).is_err() {
break;
}
});
let prev_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
ratatui::restore();
prev_hook(info);
}));
let mut terminal = ratatui::init();
if mode.needs_shared_scan(cli) {
let filtered;
let diffs: &[FileDiff] = if let Some(pattern) = &state.filter {
filtered = filter_file_diffs(state.file_diffs.clone(), pattern);
&filtered
} else {
&state.file_diffs
};
terminal.draw(|frame| {
render::render(frame, diffs, state.scroll, state.changed_only);
})?;
let registry = mode.build_registry(cli);
if let Ok(diffs) = mode.compute_diffs_with_registry(cli, ®istry) {
state.file_diffs = diffs;
state.shared_registry = Some(registry);
state.needs_redraw = true;
}
}
let result = run_event_loop(&mut terminal, &mut state, &rx, mode, cli);
ratatui::restore();
result
}
fn setup_watcher(
paths: Vec<PathBuf>,
tx: mpsc::Sender<AppEvent>,
) -> Result<notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>> {
let mut debouncer = new_debouncer(
Duration::from_millis(200),
move |events: Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>| {
if let Ok(events) = events {
let has_relevant = events.iter().any(|e| {
e.kind == DebouncedEventKind::Any
&& e.path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| {
matches!(ext, "rb" | "rs" | "py" | "js" | "jsx" | "ts" | "tsx" | "go" | "exs")
})
});
if has_relevant {
let _ = tx.send(AppEvent::FileChanged);
}
}
},
)?;
for path in paths {
if path.exists() {
debouncer
.watcher()
.watch(&path, notify::RecursiveMode::Recursive)?;
}
}
Ok(debouncer)
}
fn run_event_loop(
terminal: &mut DefaultTerminal,
state: &mut AppState,
rx: &mpsc::Receiver<AppEvent>,
mode: &WatchMode<'_>,
cli: &Cli,
) -> Result<()> {
loop {
if state.quit {
break;
}
if state.needs_redraw {
let filtered;
let diffs: &[FileDiff] = if let Some(pattern) = &state.filter {
filtered = filter_file_diffs(state.file_diffs.clone(), pattern);
&filtered
} else {
&state.file_diffs
};
let mut result = render::RenderResult { section_offsets: vec![], max_scroll: 0 };
terminal.draw(|frame| {
result = render::render(frame, diffs, state.scroll, state.changed_only);
})?;
state.section_offsets = result.section_offsets;
state.max_scroll = result.max_scroll;
state.scroll = state.scroll.min(state.max_scroll);
state.needs_redraw = false;
}
if event::poll(Duration::from_millis(50))?
&& let Event::Key(key) = event::read()?
{
handle_key(state, key);
}
match rx.try_recv() {
Ok(AppEvent::FileChanged) => {
let diffs = if let Some(reg) = &state.shared_registry {
mode.compute_diffs_with_registry(cli, reg)?
} else {
mode.compute_diffs_fast(cli)?
};
state.file_diffs = diffs;
state.needs_redraw = true;
}
Ok(AppEvent::RegistryReady(registry)) => {
if let Ok(diffs) = mode.compute_diffs_with_registry(cli, ®istry) {
state.file_diffs = diffs;
state.shared_registry = Some(registry);
state.needs_redraw = true;
}
}
Ok(AppEvent::Tick) => {}
Err(mpsc::TryRecvError::Empty) => {}
Err(mpsc::TryRecvError::Disconnected) => break,
}
}
Ok(())
}
fn handle_key(state: &mut AppState, key: KeyEvent) {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => state.quit = true,
KeyCode::Char('j') => {
state.scroll = next_section(state.scroll, &state.section_offsets);
state.needs_redraw = true;
}
KeyCode::Char('k') => {
state.scroll = prev_section(state.scroll, &state.section_offsets);
state.needs_redraw = true;
}
KeyCode::Down => {
state.scroll = state.scroll.saturating_add(1);
state.needs_redraw = true;
}
KeyCode::Up => {
state.scroll = state.scroll.saturating_sub(1);
state.needs_redraw = true;
}
KeyCode::PageDown => {
state.scroll = state.scroll.saturating_add(20);
state.needs_redraw = true;
}
KeyCode::PageUp => {
state.scroll = state.scroll.saturating_sub(20);
state.needs_redraw = true;
}
KeyCode::Home | KeyCode::Char('g') => {
state.scroll = 0;
state.needs_redraw = true;
}
KeyCode::End | KeyCode::Char('G') => {
state.scroll = usize::MAX;
state.needs_redraw = true;
}
KeyCode::Char('c') => {
state.changed_only = !state.changed_only;
state.needs_redraw = true;
}
_ => {}
}
state.scroll = state.scroll.min(state.max_scroll);
}
fn next_section(current: usize, offsets: &[usize]) -> usize {
offsets
.iter()
.find(|&&o| o > current)
.copied()
.unwrap_or(current)
}
fn prev_section(current: usize, offsets: &[usize]) -> usize {
offsets
.iter()
.rev()
.find(|&&o| o < current)
.copied()
.unwrap_or(0)
}