use anyhow::{Context, Result};
use crossterm::event::KeyCode;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, BorderType, Borders, Clear, Paragraph},
};
use tokio::sync::mpsc;
use tracing::{debug, error};
use std::path::{Path, PathBuf};
use crate::code::{Code, Language};
use crate::diff::{
Diff, NodeCache,
text::{
ChangeCounts, DiffSummary, RenderOptions, TextDiff, change_counts, is_comment_only_diff,
plain_text_line_diff, summarize_diff_with_comment_check,
},
};
use crate::review::{self, ChangeSet, ChangedFile, ReviewTarget};
use crate::tui::actions::{Action, DiffOutcome, DiffSessionData};
use crate::tui::components::{
Component,
diff_viewer::{DiffViewer, Panel},
file_dialog::FileDialog,
help_modal::HelpModal,
line_prompt::LinePrompt,
render_options_dialog::RenderOptionsDialog,
review_dialog::ReviewDialog,
search_modal::SearchModal,
theme_dialog::ThemeDialog,
};
use crate::tui::events::Event;
use crate::tui::theme::{self, OverlayTheme};
use crate::tui::ui::UI;
use crate::tui::widgets::code_viewer::DEFAULT_SYNTAX_THEME;
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub enum AppScreen {
#[default]
Viewer,
SelectFile,
SelectTheme,
RenderOptions,
Diffing,
Help,
Search,
JumpToLine,
Review,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ReviewPosition {
set: ChangeSet,
files: Vec<ChangedFile>,
index: usize,
}
fn q_should_quit(screen: AppScreen) -> bool {
matches!(screen, AppScreen::Viewer | AppScreen::Diffing)
}
fn esc_should_quit(screen: AppScreen) -> bool {
match screen {
AppScreen::Viewer => true,
AppScreen::Diffing
| AppScreen::SelectFile
| AppScreen::SelectTheme
| AppScreen::RenderOptions
| AppScreen::Help
| AppScreen::Search
| AppScreen::JumpToLine
| AppScreen::Review => false,
}
}
pub struct App {
tick_rate: f64,
frame_rate: f64,
diff_viewer: DiffViewer,
file_dialog: Option<FileDialog>,
theme_dialog: Option<ThemeDialog>,
render_options_dialog: Option<RenderOptionsDialog>,
help_modal: Option<HelpModal>,
search_modal: Option<SearchModal>,
line_prompt: Option<LinePrompt>,
review_dialog: Option<ReviewDialog>,
review_workspace: Option<review::Workspace>,
review_position: Option<ReviewPosition>,
action_tx: mpsc::UnboundedSender<Action>,
action_rx: mpsc::UnboundedReceiver<Action>,
diff_generation: u64,
restore_after_reload: Option<(Panel, usize, usize)>,
last_search_query: Option<String>,
pending_editor: Option<(PathBuf, usize)>,
pending_suspend: bool,
recent_pairs: Vec<(PathBuf, PathBuf)>,
screen: AppScreen,
dialog_target: Option<Panel>,
current_theme: OverlayTheme,
syntax_theme: Option<String>,
before_path: Option<PathBuf>,
after_path: Option<PathBuf>,
last_error: Option<String>,
diff_summary: Option<DiffSummary>,
summary_toast_ticks: u8,
diff_started_at: Option<std::time::Instant>,
change_counts: Option<ChangeCounts>,
plain_text_fallback: bool,
should_exit: bool,
render_options_override: Option<RenderOptions>,
}
fn is_suspend_key(key: &crossterm::event::KeyEvent) -> bool {
key.code == KeyCode::Char('z')
&& key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
}
pub(crate) fn editor_command(path: &Path, line: usize) -> (String, std::process::Command) {
let set = |name| {
std::env::var(name)
.ok()
.filter(|value: &String| !value.trim().is_empty())
};
let editor = set("VISUAL")
.or_else(|| set("EDITOR"))
.unwrap_or_else(|| "vi".to_string());
let line_arg = format!("+{line}");
#[cfg(unix)]
let command = {
let mut command = std::process::Command::new("sh");
command
.arg("-c")
.arg(format!("{editor} \"$@\""))
.arg(&editor)
.arg(line_arg)
.arg(path);
command
};
#[cfg(not(unix))]
let command = {
let mut words = editor.split_whitespace();
let mut command = std::process::Command::new(words.next().unwrap_or("vi"));
command.args(words).arg(line_arg).arg(path);
command
};
(editor, command)
}
fn is_interrupt_key(key: &crossterm::event::KeyEvent) -> bool {
key.code == KeyCode::Char('c')
&& key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
}
fn has_command_modifier(key: &crossterm::event::KeyEvent) -> bool {
key.modifiers
.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT)
}
impl App {
pub fn new(tick_rate: f64, frame_rate: f64) -> Result<Self> {
let (action_tx, action_rx) = mpsc::unbounded_channel();
Ok(Self {
tick_rate,
frame_rate,
diff_viewer: DiffViewer::new(),
file_dialog: None,
theme_dialog: None,
render_options_dialog: None,
help_modal: None,
search_modal: None,
review_dialog: None,
review_workspace: None,
review_position: None,
summary_toast_ticks: 0,
diff_started_at: None,
line_prompt: None,
action_tx,
action_rx,
diff_generation: 0,
restore_after_reload: None,
last_search_query: None,
pending_editor: None,
pending_suspend: false,
recent_pairs: Vec::new(),
screen: AppScreen::default(),
syntax_theme: None,
dialog_target: None,
current_theme: OverlayTheme::default(),
before_path: None,
after_path: None,
last_error: None,
diff_summary: None,
change_counts: None,
plain_text_fallback: false,
should_exit: false,
render_options_override: None,
})
}
pub async fn run(&mut self) -> Result<()> {
self.current_theme = theme::load_overlay_theme();
self.diff_viewer.set_overlay_theme(self.current_theme);
self.diff_viewer
.set_layout_override(theme::load_panel_layout());
self.diff_viewer
.set_node_highlight(theme::load_node_highlight());
self.diff_viewer.set_render_options(
self.render_options_override
.unwrap_or_else(theme::load_render_options),
);
theme::set_custom_palette(theme::load_custom_palette());
self.syntax_theme = theme::load_syntax_theme();
if let Some(name) = self.syntax_theme.clone() {
self.diff_viewer.set_syntax_theme(name);
}
self.recent_pairs = theme::load_recent_pairs();
if let Some(problem) = theme::config_error() {
self.last_error = Some(format!(
"config not loaded, using defaults and leaving the file alone - {problem}"
));
}
let mut ui = UI::new()?
.tick_rate(self.tick_rate)
.frame_rate(self.frame_rate);
ui.mouse = true;
ui.enter()?;
self.diff_viewer
.register_action_handler(self.action_tx.clone())?;
self.diff_viewer.init(ui.size()?)?;
loop {
self.handle_events(&mut ui).await?;
self.handle_actions(&mut ui)?;
if let Some((path, line)) = self.pending_editor.take() {
self.run_editor(&mut ui, &path, line)?;
}
if std::mem::take(&mut self.pending_suspend) {
self.suspend(&mut ui)?;
}
if self.should_exit {
break;
}
}
ui.exit()?;
Ok(())
}
async fn handle_events(&mut self, ui: &mut UI) -> Result<()> {
let Some(event) = ui.next_event().await else {
self.should_exit = true;
return Ok(());
};
self.handle_event(event, ui.size()?)
}
fn handle_event(&mut self, event: Event, area: Rect) -> Result<()> {
let action_tx = self.action_tx.clone();
let mut globally_handled = false;
if matches!(event, Event::Key(_)) {
self.dismiss_summary_toast();
}
if let Event::Key(key) = &event {
if is_suspend_key(key) {
action_tx.send(Action::Suspend)?;
return Ok(());
}
if is_interrupt_key(key) {
action_tx.send(Action::Quit)?;
return Ok(());
}
let bare_key = if has_command_modifier(key) {
KeyCode::Null
} else {
key.code
};
match bare_key {
KeyCode::Char('q') if q_should_quit(self.screen) => {
action_tx.send(Action::Quit)?;
}
KeyCode::Esc if esc_should_quit(self.screen) => {
action_tx.send(Action::Quit)?;
}
KeyCode::Esc if self.screen == AppScreen::Diffing => {
self.diff_generation += 1;
self.restore_after_reload = None;
self.screen = AppScreen::Viewer;
self.diff_started_at = None;
action_tx.send(Action::Render)?;
globally_handled = true;
}
KeyCode::Char('r') if self.screen == AppScreen::Viewer => {
if let Some(position) = self.review_position.clone() {
self.remember_cursor_for_restore();
self.open_review_position(position)?;
} else if let (Some(before), Some(after)) =
(self.before_path.clone(), self.after_path.clone())
{
self.remember_cursor_for_restore();
action_tx.send(Action::StartDiff(before, after))?;
}
globally_handled = true;
}
KeyCode::Char('g') if self.screen == AppScreen::Viewer => {
self.line_prompt = Some(LinePrompt::new());
self.screen = AppScreen::JumpToLine;
action_tx.send(Action::Render)?;
globally_handled = true;
}
KeyCode::Char(c @ '1'..='9')
if self.screen == AppScreen::Viewer
&& self.before_path.is_none()
&& self.after_path.is_none() =>
{
let index = (c as usize) - ('1' as usize);
if let Some((before, after)) = self.recent_pairs.get(index).cloned() {
self.open_files(before, after)?;
action_tx.send(Action::Render)?;
}
globally_handled = true;
}
KeyCode::Char('e') if self.screen == AppScreen::Viewer => {
let path = match self.diff_viewer.active_panel() {
Panel::Before => self.before_path.clone(),
Panel::After => self.after_path.clone(),
};
if let (Some(path), Some((row, _col))) =
(path, self.diff_viewer.focused_cursor_position())
{
self.pending_editor = Some((path, row + 1));
}
globally_handled = true;
}
KeyCode::Char('o') if self.screen == AppScreen::Viewer => {
let panel = self.diff_viewer.active_panel();
self.dialog_target = Some(panel);
self.last_error = None;
self.screen = AppScreen::SelectFile;
let title = match panel {
Panel::Before => "Select the BEFORE file",
Panel::After => "Select the AFTER file",
};
self.open_file_dialog(title, area)?;
action_tx.send(Action::Render)?;
globally_handled = true;
}
KeyCode::Char('c') if self.screen == AppScreen::Viewer => {
self.theme_dialog = Some(ThemeDialog::with_syntax_theme(
self.current_theme,
self.syntax_theme.as_deref(),
));
self.screen = AppScreen::SelectTheme;
action_tx.send(Action::Render)?;
globally_handled = true;
}
KeyCode::Char('M') if self.screen == AppScreen::Viewer => {
self.render_options_dialog =
Some(RenderOptionsDialog::new(self.diff_viewer.render_options()));
self.screen = AppScreen::RenderOptions;
action_tx.send(Action::Render)?;
globally_handled = true;
}
KeyCode::Char('G') if self.screen == AppScreen::Viewer => {
self.open_review();
action_tx.send(Action::Render)?;
globally_handled = true;
}
KeyCode::Char(']') if self.screen == AppScreen::Viewer => {
self.step_review_file(1)?;
action_tx.send(Action::Render)?;
globally_handled = true;
}
KeyCode::Char('[') if self.screen == AppScreen::Viewer => {
self.step_review_file(-1)?;
action_tx.send(Action::Render)?;
globally_handled = true;
}
KeyCode::Char('?') if self.screen == AppScreen::Viewer => {
self.help_modal = Some(HelpModal::new(self.current_theme));
self.screen = AppScreen::Help;
action_tx.send(Action::Render)?;
globally_handled = true;
}
KeyCode::Char('/') if self.screen == AppScreen::Viewer => {
self.search_modal = Some(SearchModal::new(self.last_search_query.clone()));
self.screen = AppScreen::Search;
action_tx.send(Action::Render)?;
globally_handled = true;
}
_ => {}
}
}
match event {
Event::Tick => action_tx.send(Action::Tick)?,
Event::Render => action_tx.send(Action::Render)?,
Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
Event::Key(_) | Event::Mouse(_) => {}
}
if !globally_handled && let Some(action) = self.dispatch_event_to_active_screen(event)? {
action_tx.send(action)?;
}
Ok(())
}
fn dispatch_event_to_active_screen(&mut self, event: Event) -> Result<Option<Action>> {
match self.screen {
AppScreen::Viewer => self.diff_viewer.handle_events(Some(event)),
AppScreen::SelectFile => match self.file_dialog.as_mut() {
Some(dialog) => dialog.handle_events(Some(event)),
None => Ok(None),
},
AppScreen::SelectTheme => match self.theme_dialog.as_mut() {
Some(dialog) => dialog.handle_events(Some(event)),
None => Ok(None),
},
AppScreen::RenderOptions => match self.render_options_dialog.as_mut() {
Some(dialog) => dialog.handle_events(Some(event)),
None => Ok(None),
},
AppScreen::Diffing => Ok(None),
AppScreen::JumpToLine => match self.line_prompt.as_mut() {
Some(prompt) => prompt.handle_events(Some(event)),
None => Ok(None),
},
AppScreen::Help => match self.help_modal.as_mut() {
Some(modal) => modal.handle_events(Some(event)),
None => Ok(None),
},
AppScreen::Search => match self.search_modal.as_mut() {
Some(modal) => modal.handle_events(Some(event)),
None => Ok(None),
},
AppScreen::Review => match self.review_dialog.as_mut() {
Some(dialog) => dialog.handle_events(Some(event)),
None => Ok(None),
},
}
}
fn open_review(&mut self) {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
match review::load(&cwd, review::DEFAULT_COMMIT_LIMIT) {
Ok(review) => {
self.review_dialog = Some(ReviewDialog::new(review));
self.screen = AppScreen::Review;
}
Err(err) => self.last_error = Some(format!("{err:#}")),
}
}
pub fn override_render_options(&mut self, options: RenderOptions) {
self.render_options_override = Some(options);
}
pub fn start_in_review(&mut self) {
self.open_review();
}
fn open_review_position(&mut self, position: ReviewPosition) -> Result<()> {
let Some(file) = position.files.get(position.index).cloned() else {
return Ok(());
};
let target = ReviewTarget {
set: position.set.clone(),
file,
};
let root = match self.review_dialog.as_ref() {
Some(dialog) => dialog.review().root.clone(),
None => review::repository_root(
&std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
)?,
};
if self.review_workspace.is_none() {
self.review_workspace = Some(review::Workspace::new()?);
}
let workspace = self.review_workspace.as_ref().expect("created just above");
self.review_position = Some(position);
match workspace.materialize(&root, &target) {
Ok((before, after)) => {
if Self::unshowable(&before)
.or_else(|| Self::unshowable(&after))
.is_some()
{
self.last_error = Some(format!(
"Binary file {} - nothing to show as text",
target.file.path
));
return Ok(());
}
let loaded = self
.diff_viewer
.set_before_file(before.clone())
.and_then(|_| self.diff_viewer.set_after_file(after.clone()));
if let Err(err) = loaded {
self.last_error = Some(format!("{err:#}"));
return Ok(());
}
self.last_error = None;
self.before_path = Some(before.clone());
self.after_path = Some(after.clone());
self.action_tx.send(Action::StartDiff(before, after))?;
}
Err(err) => self.last_error = Some(format!("{err:#}")),
}
Ok(())
}
fn handle_review_file_selected(&mut self, target: ReviewTarget, index: usize) -> Result<()> {
let files = self
.review_dialog
.as_ref()
.map(|dialog| dialog.review().files_of(&target.set).to_vec())
.unwrap_or_else(|| vec![target.file.clone()]);
let position = ReviewPosition {
set: target.set,
files,
index,
};
self.open_review_position(position)?;
self.review_dialog = None;
self.screen = AppScreen::Viewer;
Ok(())
}
fn step_review_file(&mut self, direction: i32) -> Result<()> {
let Some(mut position) = self.review_position.clone() else {
return Ok(());
};
let next = if direction < 0 {
position.index.checked_sub(1)
} else {
Some(position.index + 1).filter(|&i| i < position.files.len())
};
let Some(next) = next else {
return Ok(());
};
position.index = next;
self.open_review_position(position)
}
fn review_progress(&self) -> Option<String> {
self.review_position.as_ref().map(|position| {
format!(
"file {}/{} ({})",
position.index + 1,
position.files.len(),
position.set.label()
)
})
}
fn open_file_dialog(&mut self, title: &str, area: Rect) -> Result<()> {
let mut dialog = FileDialog::new(title);
dialog.register_action_handler(self.action_tx.clone())?;
dialog.init(area)?;
self.file_dialog = Some(dialog);
Ok(())
}
fn handle_actions(&mut self, ui: &mut UI) -> Result<()> {
while let Ok(action) = self.action_rx.try_recv() {
if action != Action::Tick && action != Action::Render {
debug!("{action:?}");
}
match &action {
Action::Tick => self.tick_summary_toast(),
Action::Quit => self.should_exit = true,
Action::Suspend => self.pending_suspend = true,
Action::Resize(w, h) => self.handle_resize(ui, *w, *h)?,
Action::Render => self.render(ui)?,
Action::FileSelected(path) => self.handle_file_selected(path.clone())?,
Action::ReviewFileSelected { target, index } => {
self.handle_review_file_selected(target.clone(), *index)?
}
Action::DialogCancelled => self.handle_dialog_cancelled()?,
Action::StartDiff(before, after) => {
self.screen = AppScreen::Diffing;
self.diff_started_at = Some(std::time::Instant::now());
self.diff_summary = None;
self.change_counts = None;
self.plain_text_fallback = false;
self.start_diff(before.clone(), after.clone());
}
Action::DiffComputed {
generation,
outcome,
} => match outcome {
DiffOutcome::Ready(data) if *generation == self.diff_generation => {
self.action_tx.send(Action::DiffReady(data.clone()))?;
}
DiffOutcome::Failed(message) if *generation == self.diff_generation => {
self.action_tx.send(Action::DiffFailed(message.clone()))?;
}
_ => {}
},
Action::DiffReady(data) => self.handle_diff_ready(data),
Action::DiffFailed(message) => {
error!("diff failed: {message}");
self.last_error = Some(message.clone());
self.screen = AppScreen::Viewer;
self.diff_started_at = None;
self.file_dialog = None;
self.diff_summary = None;
self.change_counts = None;
self.plain_text_fallback = false;
}
Action::Error(message) => {
error!("{message}");
self.last_error = Some(message.clone());
}
Action::ThemeSelected(selected_theme) => {
self.apply_theme_selection(*selected_theme)
}
Action::ThemePreviewed(previewed) => {
self.diff_viewer.set_overlay_theme(*previewed);
}
Action::SyntaxThemePreviewed(name) => {
self.diff_viewer.set_syntax_theme(name.clone());
}
Action::SearchSubmitted(query) => self.handle_search_submitted(query.clone()),
Action::SearchQueryChanged(query) => self.handle_search_query_changed(query),
Action::JumpToLineSubmitted(line) => {
self.diff_viewer.jump_to_line(*line);
self.line_prompt = None;
self.screen = AppScreen::Viewer;
}
Action::RenderOptionsChanged(options) => self.apply_render_options(*options)?,
Action::RenderOptionsAccepted => self.handle_render_options_accepted(),
_ => {}
}
self.diff_viewer.update(action.clone())?;
if let Some(dialog) = self.file_dialog.as_mut() {
dialog.update(action.clone())?;
}
if matches!(action, Action::DiffReady(_))
&& let Some((panel, row, col)) = self.restore_after_reload.take()
{
self.diff_viewer.restore_cursor(panel, row, col);
}
}
Ok(())
}
fn handle_diff_ready(&mut self, data: &DiffSessionData) {
self.screen = AppScreen::Viewer;
self.diff_started_at = None;
self.last_error = None;
self.file_dialog = None;
theme::record_recent_pair(&data.before_path, &data.after_path);
let pair = (data.before_path.clone(), data.after_path.clone());
self.recent_pairs.retain(|existing| existing != &pair);
self.recent_pairs.insert(0, pair);
self.diff_summary = summarize_diff_with_comment_check(
&data.before_contents,
&data.after_contents,
&data.before_ranges,
&data.after_ranges,
data.comment_only,
);
self.summary_toast_ticks = if self.diff_summary.is_some() {
SUMMARY_TOAST_TICKS
} else {
0
};
self.change_counts = Some(change_counts(
&data.before_contents,
&data.after_contents,
&data.before_ranges,
&data.after_ranges,
));
self.plain_text_fallback = data.plain_text_fallback;
}
fn remember_cursor_for_restore(&mut self) {
if let Some((row, col)) = self.diff_viewer.focused_cursor_position() {
self.restore_after_reload = Some((self.diff_viewer.active_panel(), row, col));
}
}
fn apply_render_options(&mut self, options: RenderOptions) -> Result<()> {
let previous = self.diff_viewer.render_options();
let needs_reload = options.needs_rebuild_from(&previous);
self.diff_viewer.set_render_options(options);
theme::save_render_options(options);
if needs_reload
&& let (Some(before), Some(after)) = (self.before_path.clone(), self.after_path.clone())
{
self.remember_cursor_for_restore();
self.action_tx.send(Action::StartDiff(before, after))?;
}
Ok(())
}
fn run_editor(&mut self, ui: &mut UI, path: &Path, line: usize) -> Result<()> {
let (editor, mut command) = editor_command(path, line);
ui.exit()?;
let status = command.status();
ui.enter()?;
ui.terminal.clear()?;
if let Err(err) = status {
self.last_error = Some(format!("failed to launch editor '{editor}': {err}"));
}
if let (Some(before), Some(after)) = (self.before_path.clone(), self.after_path.clone()) {
self.remember_cursor_for_restore();
self.action_tx.send(Action::StartDiff(before, after))?;
}
self.action_tx.send(Action::Render)?;
Ok(())
}
#[cfg(unix)]
fn suspend(&mut self, ui: &mut UI) -> Result<()> {
ui.exit()?;
unsafe {
libc::raise(libc::SIGTSTP);
}
ui.enter()?;
ui.terminal.clear()?;
self.action_tx.send(Action::Render)?;
Ok(())
}
#[cfg(not(unix))]
fn suspend(&mut self, _ui: &mut UI) -> Result<()> {
Ok(())
}
fn handle_file_selected(&mut self, path: PathBuf) -> Result<()> {
if let Some(panel) = self.dialog_target.take() {
self.select_file_for_panel(panel, path)?;
}
self.file_dialog = None;
self.screen = AppScreen::Viewer;
Ok(())
}
pub fn open_files(&mut self, before: PathBuf, after: PathBuf) -> Result<()> {
self.select_file_for_panel(Panel::Before, before)?;
self.select_file_for_panel(Panel::After, after)
}
fn unshowable(path: &Path) -> Option<String> {
match crate::code::is_binary_file(path) {
Ok(true) => Some(format!(
"Binary file {} - nothing to show as text",
path.display()
)),
Ok(false) => None,
Err(err) => Some(format!("{err:#}")),
}
}
fn select_file_for_panel(&mut self, panel: Panel, path: PathBuf) -> Result<()> {
self.review_position = None;
if let Some(problem) = Self::unshowable(&path) {
self.last_error = Some(problem);
return Ok(());
}
let loaded = match panel {
Panel::Before => self.diff_viewer.set_before_file(path.clone()),
Panel::After => self.diff_viewer.set_after_file(path.clone()),
};
if let Err(err) = loaded {
self.last_error = Some(format!("{err:#}"));
return Ok(());
}
match panel {
Panel::Before => self.before_path = Some(path),
Panel::After => self.after_path = Some(path),
}
if let (Some(before), Some(after)) = (self.before_path.clone(), self.after_path.clone()) {
self.action_tx.send(Action::StartDiff(before, after))?;
}
Ok(())
}
fn handle_dialog_cancelled(&mut self) -> Result<()> {
if self.theme_dialog.is_some() {
self.diff_viewer.set_overlay_theme(self.current_theme);
self.diff_viewer.set_syntax_theme(
self.syntax_theme
.clone()
.unwrap_or_else(|| DEFAULT_SYNTAX_THEME.to_string()),
);
theme::set_custom_palette(theme::load_custom_palette());
}
if self.search_modal.is_some() {
let last = self.last_search_query.clone().unwrap_or_default();
self.diff_viewer.preview_search(&last);
}
self.file_dialog = None;
self.theme_dialog = None;
self.render_options_dialog = None;
self.help_modal = None;
self.search_modal = None;
self.line_prompt = None;
self.review_dialog = None;
self.dialog_target = None;
self.screen = AppScreen::Viewer;
Ok(())
}
fn handle_search_submitted(&mut self, query: String) {
let query = if query.is_empty() {
self.last_search_query.clone().unwrap_or_default()
} else {
query
};
if !query.is_empty() {
self.last_search_query = Some(query.clone());
}
self.diff_viewer.search(&query);
self.search_modal = None;
self.screen = AppScreen::Viewer;
}
fn handle_search_query_changed(&mut self, query: &str) {
let count = self.diff_viewer.preview_search(query);
if let Some(modal) = self.search_modal.as_mut() {
modal.set_live_match_count(count);
}
}
fn apply_theme_selection(&mut self, selected_theme: OverlayTheme) {
self.current_theme = selected_theme;
self.syntax_theme = theme::load_syntax_theme();
self.diff_viewer.set_overlay_theme(selected_theme);
theme::save_overlay_theme(selected_theme);
self.theme_dialog = None;
self.screen = AppScreen::Viewer;
}
fn handle_render_options_accepted(&mut self) {
self.render_options_dialog = None;
self.screen = AppScreen::Viewer;
}
fn start_diff(&mut self, before: PathBuf, after: PathBuf) {
self.diff_generation += 1;
let generation = self.diff_generation;
let tx = self.action_tx.clone();
let render_options = self.diff_viewer.render_options();
tokio::task::spawn_blocking(move || {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
compute_diff_with_options(&before, &after, render_options)
}));
let outcome = match result {
Ok(Ok((data, _large_residual))) => DiffOutcome::Ready(data),
Ok(Err(err)) => DiffOutcome::Failed(err.to_string()),
Err(panic) => DiffOutcome::Failed(format!(
"internal error while diffing: {}",
panic_message(&panic)
)),
};
let _ = tx.send(Action::DiffComputed {
generation,
outcome,
});
});
}
fn handle_resize(&mut self, ui: &mut UI, w: u16, h: u16) -> Result<()> {
ui.resize(Rect::new(0, 0, w, h))?;
self.render(ui)?;
Ok(())
}
fn render(&mut self, ui: &mut UI) -> Result<()> {
let palette = self.diff_viewer.overlay_theme().palette();
ui.draw(&palette, |frame| {
let area = frame.area();
let result = match self.screen {
AppScreen::Viewer => self.draw_viewer(frame, area),
AppScreen::SelectFile => match self.file_dialog.as_mut() {
Some(dialog) => dialog.draw(frame, area),
None => Ok(()),
},
AppScreen::SelectTheme => self.draw_theme_dialog(frame, area),
AppScreen::RenderOptions => self.draw_render_options_dialog(frame, area),
AppScreen::Diffing => {
let elapsed = self.diff_started_at.map(|start| start.elapsed());
frame.render_widget(diffing_status_paragraph(elapsed), area);
Ok(())
}
AppScreen::Help => self.draw_help_modal(frame, area),
AppScreen::Search => self.draw_search_modal(frame, area),
AppScreen::JumpToLine => self.draw_line_prompt(frame, area),
AppScreen::Review => self.draw_review_dialog(frame, area),
};
if let Err(err) = result {
let _ = self
.action_tx
.send(Action::Error(format!("Failed to draw: {:?}", err)));
}
})?;
Ok(())
}
pub(crate) fn load_still(
&mut self,
before: &Path,
after: &Path,
area: Rect,
overlay: OverlayTheme,
syntax_theme: Option<String>,
) -> Result<()> {
self.current_theme = overlay;
self.diff_viewer.set_overlay_theme(overlay);
if let Some(name) = syntax_theme {
self.diff_viewer.set_syntax_theme(name.clone());
self.syntax_theme = Some(name);
}
self.diff_viewer.init(area)?;
self.before_path = Some(before.to_path_buf());
self.after_path = Some(after.to_path_buf());
let (data, _large_residual) =
compute_diff_with_options(before, after, self.diff_viewer.render_options())?;
self.handle_diff_ready(&data);
self.diff_viewer.update(Action::DiffReady(data))?;
Ok(())
}
pub(crate) fn draw_viewer(&mut self, frame: &mut ratatui::Frame, area: Rect) -> Result<()> {
let mut constraints = Vec::with_capacity(4);
if self.diff_summary.is_some() {
constraints.push(Constraint::Length(1));
}
constraints.push(Constraint::Min(1));
if self.last_error.is_some() {
constraints.push(Constraint::Length(1));
}
constraints.push(Constraint::Length(1));
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
let mut next = 0;
if let Some(summary) = self.diff_summary {
frame.render_widget(status_bar_paragraph(summary), layout[next]);
next += 1;
}
self.diff_viewer.draw(frame, layout[next])?;
self.draw_recent_pairs(frame, layout[next]);
next += 1;
if let Some(message) = &self.last_error {
frame.render_widget(
Paragraph::new(message.as_str()).style(Style::new().fg(Color::Red)),
layout[next],
);
next += 1;
}
self.draw_footer(frame, layout[next]);
self.draw_summary_toast(frame, area);
Ok(())
}
fn draw_summary_toast(&self, frame: &mut ratatui::Frame, area: Rect) {
let Some(summary) = self.diff_summary else {
return;
};
if self.summary_toast_ticks == 0 {
return;
}
let popup = summary_toast_area(summary, area);
frame.render_widget(Clear, popup);
frame.render_widget(summary_toast_paragraph(summary), popup);
}
fn tick_summary_toast(&mut self) {
self.summary_toast_ticks = self.summary_toast_ticks.saturating_sub(1);
}
fn dismiss_summary_toast(&mut self) {
self.summary_toast_ticks = 0;
}
fn draw_footer(&self, frame: &mut ratatui::Frame, area: Rect) {
let mut left_parts = Vec::with_capacity(3);
if let Some((row, col)) = self.diff_viewer.focused_cursor_character_position() {
left_parts.push(format!("Ln {}, Col {}", row + 1, col + 1));
}
if let Some(counts) = self.change_counts {
let counts_text = format_change_counts(counts);
if !counts_text.is_empty() {
left_parts.push(counts_text);
}
}
if let Some((index, total)) = self.diff_viewer.focused_search_match_count_and_index() {
left_parts.push(format!("match {index}/{total}"));
} else if let Some((index, total)) = self.diff_viewer.merged_change_count_and_index() {
left_parts.push(format!("change {index}/{total}"));
}
if let Some(progress) = self.review_progress() {
left_parts.push(progress);
}
if self.plain_text_fallback {
left_parts.push("[plain text]".to_string());
}
let layout = self.diff_viewer.layout_override();
if layout != crate::tui::theme::PanelLayout::Auto {
left_parts.push(format!("[layout: {}]", layout.label()));
}
if let Some(badge) = render_options_badge(self.diff_viewer.render_options()) {
left_parts.push(badge);
}
let left = left_parts.join(" ");
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(70), Constraint::Min(1)])
.split(area);
frame.render_widget(
Paragraph::new(left).style(Style::new().fg(Color::DarkGray)),
layout[0],
);
frame.render_widget(
Paragraph::new(FOOTER_HINTS)
.style(Style::new().fg(Color::DarkGray))
.alignment(Alignment::Right),
layout[1],
);
}
fn draw_recent_pairs(&self, frame: &mut ratatui::Frame, area: Rect) {
if self.before_path.is_some() || self.after_path.is_some() || self.recent_pairs.is_empty() {
return;
}
let shown = self.recent_pairs.len().min(9);
let mut lines = vec![ratatui::text::Line::from("Recent pairs".to_string())];
for (i, (before, after)) in self.recent_pairs.iter().take(shown).enumerate() {
lines.push(ratatui::text::Line::from(format!(
" {} {} \u{2194} {}",
i + 1,
before.display(),
after.display()
)));
}
lines.push(ratatui::text::Line::from(
" press a digit to reopen, or 'o' to browse".to_string(),
));
let width = (lines
.iter()
.map(|l| l.width() as u16)
.max()
.unwrap_or(20)
.saturating_add(4))
.min(area.width);
let height = (lines.len() as u16 + 2).min(area.height);
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let popup = Rect::new(x, y, width, height);
frame.render_widget(Clear, popup);
frame.render_widget(
Paragraph::new(lines).block(
ratatui::widgets::Block::default()
.borders(ratatui::widgets::Borders::ALL)
.border_style(Style::new().fg(Color::Cyan)),
),
popup,
);
}
fn draw_theme_dialog(&mut self, frame: &mut ratatui::Frame, area: Rect) -> Result<()> {
self.draw_viewer(frame, area)?;
let Some(dialog) = self.theme_dialog.as_mut() else {
return Ok(());
};
let popup = dialog.popup_area(area);
frame.render_widget(Clear, popup);
dialog.draw(frame, popup)
}
fn draw_render_options_dialog(&mut self, frame: &mut ratatui::Frame, area: Rect) -> Result<()> {
self.draw_viewer(frame, area)?;
let Some(dialog) = self.render_options_dialog.as_mut() else {
return Ok(());
};
let popup = dialog.popup_area(area);
frame.render_widget(Clear, popup);
dialog.draw(frame, popup)
}
fn draw_line_prompt(&mut self, frame: &mut ratatui::Frame, area: Rect) -> Result<()> {
self.draw_viewer(frame, area)?;
let Some(prompt) = self.line_prompt.as_mut() else {
return Ok(());
};
let popup = prompt.popup_area(area);
frame.render_widget(Clear, popup);
prompt.draw(frame, popup)?;
let (x, y) = prompt.cursor_screen_position(popup);
frame.set_cursor_position((x, y));
Ok(())
}
fn draw_help_modal(&mut self, frame: &mut ratatui::Frame, area: Rect) -> Result<()> {
self.draw_viewer(frame, area)?;
let Some(modal) = self.help_modal.as_mut() else {
return Ok(());
};
let popup = modal.popup_area(area);
frame.render_widget(Clear, popup);
modal.draw(frame, popup)
}
fn draw_review_dialog(&mut self, frame: &mut ratatui::Frame, area: Rect) -> Result<()> {
self.draw_viewer(frame, area)?;
let Some(dialog) = self.review_dialog.as_mut() else {
return Ok(());
};
let popup = dialog.popup_area(area);
frame.render_widget(Clear, popup);
dialog.draw(frame, popup)
}
fn draw_search_modal(&mut self, frame: &mut ratatui::Frame, area: Rect) -> Result<()> {
self.draw_viewer(frame, area)?;
let Some(modal) = self.search_modal.as_mut() else {
return Ok(());
};
let popup = modal.popup_area(area);
frame.render_widget(Clear, popup);
modal.draw(frame, popup)?;
let (x, y) = modal.cursor_screen_position(popup);
frame.set_cursor_position((x, y));
Ok(())
}
}
pub(crate) const FOOTER_HINTS: &str =
"?:help o:open G:git r:reload n/p:next/prev /:search M:options Tab:switch q:quit";
fn render_options_badge(options: RenderOptions) -> Option<String> {
if options == RenderOptions::FULL {
return None;
}
if options == RenderOptions::MINIMAL {
return Some("[minimal]".to_string());
}
let full = RenderOptions::FULL.options();
let differing = |state: bool| -> Vec<&str> {
options
.options()
.into_iter()
.zip(full)
.filter(|((_, on), (_, on_in_full))| on != on_in_full && *on == state)
.map(|((label, _), _)| label)
.collect()
};
let (off, on) = (differing(false), differing(true));
let mut parts = Vec::new();
if !off.is_empty() {
parts.push(format!("{} off", off.join(", ")));
}
if !on.is_empty() {
parts.push(format!("{} on", on.join(", ")));
}
Some(format!("[{}]", parts.join("; ")))
}
fn format_change_counts(counts: ChangeCounts) -> String {
let mut parts = Vec::with_capacity(4);
if counts.insertions > 0 {
parts.push(format!("+{}", counts.insertions));
}
if counts.deletions > 0 {
parts.push(format!("-{}", counts.deletions));
}
if counts.updates > 0 {
parts.push(format!("~{}", counts.updates));
}
if counts.moves > 0 {
parts.push(format!("M{}", counts.moves));
}
parts.join(" ")
}
fn diffing_status_paragraph(elapsed: Option<std::time::Duration>) -> Paragraph<'static> {
let text = match elapsed {
Some(elapsed) => format!(
"Diffing\u{2026} {:.1}s (Esc cancels)",
elapsed.as_secs_f64()
),
None => "Diffing\u{2026} (Esc cancels)".to_string(),
};
Paragraph::new(text).alignment(Alignment::Center)
}
const SUMMARY_TOAST_TICKS: u8 = 16;
fn summary_toast_area(summary: DiffSummary, area: Rect) -> Rect {
let width = (summary.label().chars().count() as u16 + 4).min(area.width);
let height = 3.min(area.height);
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
Rect::new(x, y, width, height)
}
fn summary_toast_paragraph(summary: DiffSummary) -> Paragraph<'static> {
let color = summary_color(summary);
Paragraph::new(summary.label())
.style(Style::new().fg(color).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(color)),
)
}
fn summary_color(summary: DiffSummary) -> Color {
match summary {
DiffSummary::NoChanges => Color::DarkGray,
DiffSummary::NewFile => Color::Green,
DiffSummary::DeletedFile => Color::Red,
DiffSummary::WhitespaceOnly => Color::Cyan,
DiffSummary::CommentOnly => Color::Blue,
DiffSummary::RefactorMovedOnly => Color::Yellow,
}
}
fn status_bar_paragraph(summary: DiffSummary) -> Paragraph<'static> {
let color = summary_color(summary);
Paragraph::new(summary.label())
.style(Style::new().fg(color).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
}
fn parse_before_after(before: &Path, after: &Path) -> Result<(Code, Code)> {
let mut before_code = Code::from_file(before)?;
let mut after_code = Code::from_file(after)?;
let before_language = before_code.metadata.language;
let after_language = after_code.metadata.language;
substitute_missing_language(&mut before_code, after_language, before);
substitute_missing_language(&mut after_code, before_language, after);
Ok((before_code, after_code))
}
#[allow(clippy::too_many_arguments)]
fn assemble_diff_session_data(
before_path: &Path,
after_path: &Path,
before_code: &Code,
after_code: &Code,
diff: &Diff,
render_options: RenderOptions,
) -> Result<DiffSessionData> {
let node_cache = NodeCache::build(before_code, after_code);
let ast = diff.ast.as_ref().context("diff produced no AST mapping")?;
let text_diff =
TextDiff::from_with_options(before_code, after_code, ast, &node_cache, render_options);
let comment_only = is_comment_only_diff(before_code, after_code, ast, &node_cache);
Ok(DiffSessionData {
before_path: before_path.to_path_buf(),
after_path: after_path.to_path_buf(),
before_contents: display_safe(&before_code.contents),
after_contents: display_safe(&after_code.contents),
before_ranges: text_diff.all(0),
after_ranges: text_diff.all(1),
comment_only,
plain_text_fallback: false,
})
}
fn display_safe(text: &str) -> String {
let bytes = text.as_bytes();
text.char_indices()
.map(|(index, character)| {
let kept = character == '\n'
|| character == '\t'
|| (character == '\r' && bytes.get(index + 1) == Some(&b'\n'));
if !kept && character.is_ascii_control() {
' '
} else {
character
}
})
.collect()
}
fn assemble_plain_text_diff_session_data(
before_path: &Path,
after_path: &Path,
before_code: &Code,
after_code: &Code,
) -> DiffSessionData {
let (before_ranges, after_ranges) =
plain_text_line_diff(&before_code.contents, &after_code.contents);
DiffSessionData {
before_path: before_path.to_path_buf(),
after_path: after_path.to_path_buf(),
before_contents: display_safe(&before_code.contents),
after_contents: display_safe(&after_code.contents),
before_ranges,
after_ranges,
comment_only: false,
plain_text_fallback: true,
}
}
#[cfg(test)]
pub(crate) fn compute_diff(before: &Path, after: &Path) -> Result<(DiffSessionData, bool)> {
compute_diff_with_options(before, after, RenderOptions::FULL)
}
pub const DIFF_COMPUTE_STACK_SIZE: usize = 256 * 1024 * 1024;
pub const DIFF_THREAD_NAME: &str = "omnidiff-diff";
pub fn compute_diff_with_options(
before: &Path,
after: &Path,
render_options: RenderOptions,
) -> Result<(DiffSessionData, bool)> {
let before = before.to_path_buf();
let after = after.to_path_buf();
match std::thread::Builder::new()
.name(DIFF_THREAD_NAME.to_string())
.stack_size(DIFF_COMPUTE_STACK_SIZE)
.spawn(move || compute_diff_with_options_inner(&before, &after, render_options))
.expect("failed to spawn diff-computation thread")
.join()
{
Ok(result) => result,
Err(panic) => std::panic::resume_unwind(panic),
}
}
fn compute_diff_with_options_inner(
before: &Path,
after: &Path,
render_options: RenderOptions,
) -> Result<(DiffSessionData, bool)> {
let (before_code, after_code) = parse_before_after(before, after)?;
if before_code.ast.is_none() || after_code.ast.is_none() {
let data = assemble_plain_text_diff_session_data(before, after, &before_code, &after_code);
return Ok((data, false));
}
let pending = Diff::pending(&before_code, &after_code);
let large_residual = pending.large_residual();
let diff = pending.finish();
let data = assemble_diff_session_data(
before,
after,
&before_code,
&after_code,
&diff,
render_options,
)?;
Ok((data, large_residual))
}
fn substitute_missing_language(code: &mut Code, fallback_language: Option<Language>, path: &Path) {
if code.ast.is_none()
&& code.contents.is_empty()
&& let Some(language) = fallback_language
{
*code = Code::from_string("", &language);
code.metadata.path = Some(path.to_path_buf());
}
}
fn panic_message(panic: &Box<dyn std::any::Any + Send>) -> String {
if let Some(message) = panic.downcast_ref::<&str>() {
message.to_string()
} else if let Some(message) = panic.downcast_ref::<String>() {
message.clone()
} else {
"unknown panic".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn write_temp_file(contents: &str) -> NamedTempFile {
let mut file = NamedTempFile::new().expect("create temp file");
file.write_all(contents.as_bytes())
.expect("write temp file");
file
}
#[test]
fn panic_message_extracts_str_payload() {
let panic: Box<dyn std::any::Any + Send> = Box::new("boom");
assert_eq!(panic_message(&panic), "boom");
}
#[test]
fn panic_message_extracts_string_payload() {
let panic: Box<dyn std::any::Any + Send> = Box::new("boom".to_string());
assert_eq!(panic_message(&panic), "boom");
}
#[test]
fn panic_message_falls_back_for_unknown_payload_type() {
let panic: Box<dyn std::any::Any + Send> = Box::new(42i32);
assert_eq!(panic_message(&panic), "unknown panic");
}
#[test]
fn select_file_for_panel_only_starts_diff_once_both_sides_are_set() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
let before = write_temp_file("before contents");
let after = write_temp_file("after contents");
app.select_file_for_panel(Panel::Before, before.path().to_path_buf())?;
assert_eq!(app.before_path, Some(before.path().to_path_buf()));
assert!(
app.action_rx.try_recv().is_err(),
"StartDiff must not fire with only one side set"
);
app.select_file_for_panel(Panel::After, after.path().to_path_buf())?;
assert_eq!(app.after_path, Some(after.path().to_path_buf()));
let action = app
.action_rx
.try_recv()
.expect("StartDiff must fire once both sides are set");
assert_eq!(
action,
Action::StartDiff(before.path().to_path_buf(), after.path().to_path_buf())
);
Ok(())
}
#[cfg(unix)]
#[test]
fn compute_diff_treats_dev_null_before_as_an_empty_file_in_the_afters_language() -> Result<()> {
let after = tempfile::Builder::new()
.suffix(".rs")
.tempfile()
.expect("create temp file");
std::fs::write(after.path(), "fn main() {}\n").expect("write temp file");
let (data, _large_residual) = compute_diff(Path::new("/dev/null"), after.path())?;
assert_eq!(data.before_contents, "");
assert_eq!(data.after_contents, "fn main() {}\n");
assert!(
!data.after_ranges.is_empty(),
"the whole after-file should show up as inserted"
);
Ok(())
}
#[cfg(unix)]
#[test]
fn compute_diff_treats_dev_null_after_as_an_empty_file_in_the_befores_language() -> Result<()> {
let before = tempfile::Builder::new()
.suffix(".rs")
.tempfile()
.expect("create temp file");
std::fs::write(before.path(), "fn main() {}\n").expect("write temp file");
let (data, _large_residual) = compute_diff(before.path(), Path::new("/dev/null"))?;
assert_eq!(data.before_contents, "fn main() {}\n");
assert_eq!(data.after_contents, "");
assert!(
!data.before_ranges.is_empty(),
"the whole before-file should show up as deleted"
);
Ok(())
}
#[test]
fn compute_diff_falls_back_to_plain_text_when_neither_side_has_a_recognizable_language()
-> Result<()> {
let before = write_temp_file("hello");
let after = write_temp_file("world");
let (data, large_residual) = compute_diff(before.path(), after.path())?;
assert!(
data.plain_text_fallback,
"an unrecognized language on both sides should route through plain_text_line_diff"
);
assert!(
!large_residual,
"large_residual is a property of the AST residual, and no AST algorithm ran here"
);
assert_eq!(data.before_contents, "hello");
assert_eq!(data.after_contents, "world");
assert!(!data.before_ranges.is_empty());
assert!(!data.after_ranges.is_empty());
Ok(())
}
#[test]
fn open_files_loads_both_panels_and_starts_diff() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
let before = write_temp_file("before contents");
let after = write_temp_file("after contents");
app.open_files(before.path().to_path_buf(), after.path().to_path_buf())?;
assert_eq!(app.before_path, Some(before.path().to_path_buf()));
assert_eq!(app.after_path, Some(after.path().to_path_buf()));
assert!(app.action_rx.try_recv().is_ok());
Ok(())
}
fn enter_sample_repository() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let git = |args: &[&str]| {
let status = std::process::Command::new("git")
.arg("-C")
.arg(root)
.args([
"-c",
"user.name=T",
"-c",
"user.email=t@example.com",
"-c",
"commit.gpgsign=false",
])
.args(args)
.status()
.unwrap();
assert!(status.success(), "git {args:?}");
};
git(&["init", "-q"]);
std::fs::write(root.join("a.rs"), "fn a() {}\n").unwrap();
std::fs::write(root.join("b.rs"), "fn b() {}\n").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "first"]);
std::fs::write(root.join("a.rs"), "fn a() { 1 }\n").unwrap();
std::fs::write(root.join("b.rs"), "fn b() { 2 }\n").unwrap();
std::env::set_current_dir(root).unwrap();
dir
}
#[test]
fn opening_a_reviewed_file_sets_the_position_and_stepping_walks_its_set() -> Result<()> {
let dir = enter_sample_repository();
let mut app = App::new(4.0, 60.0)?;
app.open_review();
assert_eq!(app.screen, AppScreen::Review);
let review = app.review_dialog.as_ref().unwrap().review().clone();
assert_eq!(review.working_tree.len(), 2);
let target = ReviewTarget {
set: ChangeSet::WorkingTree,
file: review.working_tree[0].clone(),
};
app.handle_review_file_selected(target, 0)?;
assert_eq!(app.screen, AppScreen::Viewer);
assert!(app.review_dialog.is_none());
let position = app
.review_position
.clone()
.expect("a reviewed pair is open");
assert_eq!((position.index, position.files.len()), (0, 2));
assert!(
app.before_path
.as_ref()
.unwrap()
.starts_with(std::env::temp_dir()),
"the index blob is materialized"
);
assert_eq!(
app.after_path.as_ref().unwrap().canonicalize()?,
dir.path().canonicalize()?.join("a.rs"),
"the working tree side is the real file"
);
assert!(matches!(
app.action_rx.try_recv(),
Ok(Action::StartDiff(_, _))
));
app.step_review_file(1)?;
assert_eq!(app.review_position.as_ref().unwrap().index, 1);
assert!(app.after_path.as_ref().unwrap().ends_with("b.rs"));
app.step_review_file(1)?;
assert_eq!(
app.review_position.as_ref().unwrap().index,
1,
"stops at the end"
);
app.step_review_file(-1)?;
assert_eq!(app.review_position.as_ref().unwrap().index, 0);
let terminal = draw_viewer_once(&mut app)?;
assert!(rendered_text(&terminal).contains("file 1/2 (working tree)"));
app.select_file_for_panel(Panel::Before, dir.path().join("a.rs"))?;
assert!(
app.review_position.is_none(),
"a file opened by hand leaves review mode"
);
Ok(())
}
#[test]
fn a_binary_reviewed_file_is_a_banner_not_a_crash_and_stepping_moves_past_it() -> Result<()> {
let dir = enter_sample_repository();
std::fs::write(dir.path().join("a.rs"), [0xff, 0xfe, 0x00, 0x41]).unwrap();
let mut app = App::new(4.0, 60.0)?;
app.open_review();
let review = app.review_dialog.as_ref().unwrap().review().clone();
assert_eq!(review.working_tree[0].path, "a.rs");
let target = ReviewTarget {
set: ChangeSet::WorkingTree,
file: review.working_tree[0].clone(),
};
app.handle_review_file_selected(target, 0)?;
assert_eq!(app.screen, AppScreen::Viewer);
assert_eq!(
app.last_error.as_deref(),
Some("Binary file a.rs - nothing to show as text")
);
assert!(
app.before_path.is_none(),
"nothing was loaded into the panels"
);
assert!(app.action_rx.try_recv().is_err(), "no diff was started");
assert_eq!(
app.review_position.as_ref().unwrap().index,
0,
"but the position is kept"
);
app.step_review_file(1)?;
assert_eq!(app.review_position.as_ref().unwrap().index, 1);
assert_eq!(
app.last_error, None,
"the next file opens normally and clears the banner"
);
assert!(app.after_path.as_ref().unwrap().ends_with("b.rs"));
assert!(matches!(
app.action_rx.try_recv(),
Ok(Action::StartDiff(_, _))
));
Ok(())
}
#[test]
fn picking_a_binary_file_by_hand_is_a_banner_not_a_crash() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
let mut binary = tempfile::Builder::new().suffix(".pdf").tempfile()?;
std::io::Write::write_all(&mut binary, &[0x25, 0x50, 0x44, 0x46, 0xff, 0xfe])?;
app.select_file_for_panel(Panel::Before, binary.path().to_path_buf())?;
assert!(
app.last_error
.as_deref()
.unwrap()
.starts_with("Binary file ")
);
assert!(app.before_path.is_none());
let missing = std::env::temp_dir().join("omnidiff-no-such-file-ever.rs");
app.select_file_for_panel(Panel::After, missing)?;
assert!(
app.last_error
.as_deref()
.unwrap()
.contains("Failed to read file")
);
assert!(app.after_path.is_none());
assert!(
app.action_rx.try_recv().is_err(),
"no diff was started for either"
);
Ok(())
}
#[test]
fn opening_the_review_outside_a_repository_goes_to_the_banner() -> Result<()> {
let dir = tempfile::tempdir()?;
std::env::set_current_dir(dir.path())?;
let mut app = App::new(4.0, 60.0)?;
app.open_review();
assert_eq!(app.screen, AppScreen::Viewer);
assert!(
app.last_error
.as_deref()
.unwrap_or("")
.contains("not inside a git repository")
);
Ok(())
}
#[test]
fn esc_should_quit_is_false_for_every_screen_with_its_own_dialog() {
assert!(!esc_should_quit(AppScreen::SelectFile));
assert!(!esc_should_quit(AppScreen::SelectTheme));
assert!(!esc_should_quit(AppScreen::Help));
assert!(!esc_should_quit(AppScreen::Search));
assert!(!esc_should_quit(AppScreen::JumpToLine));
assert!(!esc_should_quit(AppScreen::Review));
}
#[test]
fn q_quits_only_from_the_viewer_and_the_diffing_wait() {
assert!(q_should_quit(AppScreen::Viewer));
assert!(q_should_quit(AppScreen::Diffing));
for screen in [
AppScreen::SelectFile,
AppScreen::SelectTheme,
AppScreen::RenderOptions,
AppScreen::Help,
AppScreen::Search,
AppScreen::JumpToLine,
AppScreen::Review,
] {
assert!(!q_should_quit(screen), "{screen:?}");
}
}
#[test]
fn esc_should_quit_is_true_only_on_the_bare_viewer() {
assert!(esc_should_quit(AppScreen::Viewer));
assert!(!esc_should_quit(AppScreen::Diffing));
}
#[test]
fn a_stale_diff_computed_result_is_dropped_after_cancel() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
app.screen = AppScreen::Diffing;
app.diff_generation = 1;
app.diff_generation += 1;
app.screen = AppScreen::Viewer;
app.action_tx.send(Action::DiffComputed {
generation: 1,
outcome: DiffOutcome::Failed("too late".to_string()),
})?;
let mut ui_free_actions = Vec::new();
while let Ok(action) = app.action_rx.try_recv() {
match &action {
Action::DiffComputed { .. } => {
if let Action::DiffComputed { generation, .. } = &action
&& *generation == app.diff_generation
{
ui_free_actions.push(action.clone());
}
}
other => ui_free_actions.push(other.clone()),
}
}
assert!(
ui_free_actions.is_empty(),
"a stale generation must produce no follow-up actions: {ui_free_actions:?}"
);
assert!(app.last_error.is_none());
Ok(())
}
#[test]
fn ctrl_z_is_recognised_as_suspend_and_a_bare_z_is_not() {
use crossterm::event::{KeyEvent, KeyModifiers};
assert!(is_suspend_key(&KeyEvent::new(
KeyCode::Char('z'),
KeyModifiers::CONTROL
)));
assert!(!is_suspend_key(&KeyEvent::new(
KeyCode::Char('z'),
KeyModifiers::NONE
)));
assert!(!is_suspend_key(&KeyEvent::new(
KeyCode::Char('c'),
KeyModifiers::CONTROL
)));
assert!(!is_suspend_key(&KeyEvent::new(
KeyCode::Char('z'),
KeyModifiers::SHIFT
)));
}
#[cfg(unix)]
#[test]
fn an_editor_with_arguments_gets_them_and_then_the_line_and_path() {
unsafe {
std::env::set_var("VISUAL", "");
std::env::set_var("EDITOR", "printf '%s|'");
}
let (editor, mut command) = editor_command(Path::new("/tmp/a file.rs"), 7);
let output = command.output().expect("run sh");
unsafe {
std::env::remove_var("VISUAL");
std::env::remove_var("EDITOR");
}
assert_eq!(editor, "printf '%s|'", "an empty VISUAL counts as unset");
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"+7|/tmp/a file.rs|"
);
}
#[test]
fn ctrl_c_quits_and_ctrl_e_reaches_the_viewer_instead_of_the_editor() -> Result<()> {
use crossterm::event::{KeyEvent, KeyModifiers};
let mut app = App::new(4.0, 60.0)?;
app.screen = AppScreen::Viewer;
app.before_path = Some(PathBuf::from("before.rs"));
app.diff_viewer.load_diff(&DiffSessionData {
before_path: PathBuf::from("before.rs"),
after_path: PathBuf::from("after.rs"),
before_contents: "foo\nbar\n".to_string(),
after_contents: "foo\nbar\n".to_string(),
before_ranges: Vec::new(),
after_ranges: Vec::new(),
comment_only: false,
plain_text_fallback: false,
});
let area = Rect::new(0, 0, 120, 40);
let press = |app: &mut App, code, modifiers| -> Result<Vec<Action>> {
app.handle_event(Event::Key(KeyEvent::new(code, modifiers)), area)?;
let mut actions = Vec::new();
while let Ok(action) = app.action_rx.try_recv() {
actions.push(action);
}
Ok(actions)
};
assert!(
press(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL)?.contains(&Action::Quit),
"Ctrl-C must quit"
);
assert_eq!(app.screen, AppScreen::Viewer, "not open the theme dialog");
press(&mut app, KeyCode::Char('e'), KeyModifiers::CONTROL)?;
assert!(
app.pending_editor.is_none(),
"Ctrl-E is the viewer's scroll, not the editor"
);
press(&mut app, KeyCode::Char('e'), KeyModifiers::NONE)?;
assert!(app.pending_editor.is_some(), "a bare e opens the editor");
press(&mut app, KeyCode::Char('c'), KeyModifiers::NONE)?;
assert_eq!(app.screen, AppScreen::SelectTheme);
Ok(())
}
#[test]
fn handle_dialog_cancelled_resets_dialog_state() -> Result<()> {
let mut app = App::new(4.0, 60.0).expect("construct App");
app.screen = AppScreen::SelectFile;
app.dialog_target = Some(Panel::Before);
app.handle_dialog_cancelled()?;
assert_eq!(app.screen, AppScreen::Viewer);
assert_eq!(app.dialog_target, None);
assert!(app.file_dialog.is_none());
Ok(())
}
#[test]
fn closing_the_render_options_panel_keeps_what_was_toggled_and_persisted() -> Result<()> {
let config = tempfile::NamedTempFile::new().expect("temp config");
unsafe { std::env::set_var(theme::CONFIG_ENV, config.path()) };
let mut app = App::new(4.0, 60.0).expect("construct App");
app.diff_viewer.set_render_options(RenderOptions::FULL);
app.screen = AppScreen::RenderOptions;
app.render_options_dialog = Some(RenderOptionsDialog::new(RenderOptions::FULL));
app.apply_render_options(RenderOptions::MINIMAL)?;
assert_eq!(app.diff_viewer.render_options(), RenderOptions::MINIMAL);
assert_eq!(theme::load_render_options(), RenderOptions::MINIMAL);
app.handle_dialog_cancelled()?;
assert_eq!(app.diff_viewer.render_options(), RenderOptions::MINIMAL);
assert!(app.render_options_dialog.is_none());
app.render_options_dialog = Some(RenderOptionsDialog::new(RenderOptions::MINIMAL));
app.handle_render_options_accepted();
assert_eq!(app.diff_viewer.render_options(), RenderOptions::MINIMAL);
assert_eq!(theme::load_render_options(), RenderOptions::MINIMAL);
assert!(app.render_options_dialog.is_none());
unsafe { std::env::remove_var(theme::CONFIG_ENV) };
Ok(())
}
#[test]
fn apply_render_options_reloads_when_whole_pair_updates_changes() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
app.before_path = Some(PathBuf::from("before.rs"));
app.after_path = Some(PathBuf::from("after.rs"));
assert!(!app.diff_viewer.render_options().whole_pair_updates);
app.apply_render_options(RenderOptions {
whole_pair_updates: true,
..RenderOptions::default()
})?;
assert!(app.diff_viewer.render_options().whole_pair_updates);
let queued: Vec<_> = std::iter::from_fn(|| app.action_rx.try_recv().ok()).collect();
assert!(
matches!(
queued.as_slice(),
[Action::StartDiff(before, after)]
if before == Path::new("before.rs") && after == Path::new("after.rs")
),
"expected exactly one StartDiff reload, got {queued:?}"
);
Ok(())
}
#[test]
fn apply_render_options_reloads_for_every_construction_time_field() -> Result<()> {
let construction_time = RenderOptions::default().options().len();
for index in 2..construction_time {
let mut app = App::new(4.0, 60.0)?;
app.before_path = Some(PathBuf::from("before.rs"));
app.after_path = Some(PathBuf::from("after.rs"));
let mut options = app.diff_viewer.render_options();
options.toggle(index);
app.apply_render_options(options)?;
let queued: Vec<_> = std::iter::from_fn(|| app.action_rx.try_recv().ok()).collect();
assert!(
matches!(queued.as_slice(), [Action::StartDiff(_, _)]),
"toggling option {index} ({}) should reload, got {queued:?}",
options.options()[index].0
);
}
Ok(())
}
#[test]
fn apply_render_options_does_not_reload_for_the_other_fields() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
app.before_path = Some(PathBuf::from("before.rs"));
app.after_path = Some(PathBuf::from("after.rs"));
app.apply_render_options(RenderOptions {
leading_whitespace: false,
structural_punctuation: false,
whole_pair_updates: false,
paint_reindent_only_moves: true,
paint_displaced_moves: true,
paint_resized_moves: true,
whole_identifier_updates: true,
})?;
let queued: Vec<_> = std::iter::from_fn(|| app.action_rx.try_recv().ok()).collect();
assert!(
queued.is_empty(),
"leading_whitespace/structural_punctuation must not trigger a reload: {queued:?}"
);
Ok(())
}
#[test]
fn apply_render_options_with_no_open_pair_does_not_queue_a_reload() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
assert!(app.before_path.is_none());
assert!(app.after_path.is_none());
app.apply_render_options(RenderOptions {
whole_pair_updates: true,
..RenderOptions::default()
})?;
assert!(app.diff_viewer.render_options().whole_pair_updates);
let queued: Vec<_> = std::iter::from_fn(|| app.action_rx.try_recv().ok()).collect();
assert!(queued.is_empty(), "nothing to reload: {queued:?}");
Ok(())
}
#[test]
fn redelivering_the_opening_keystroke_would_immediately_close_the_help_modal() {
use crossterm::event::{KeyEvent, KeyModifiers};
let mut app = App::new(4.0, 60.0).expect("construct App");
app.help_modal = Some(crate::tui::components::help_modal::HelpModal::new(
OverlayTheme::default(),
));
app.screen = AppScreen::Help;
let event = Event::Key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
let action = app.dispatch_event_to_active_screen(event).unwrap();
assert_eq!(
action,
Some(Action::DialogCancelled),
"confirms why handle_events must skip re-dispatching the keystroke that just opened \
this screen, not just document it"
);
}
#[test]
fn handle_search_submitted_jumps_the_focused_panel_and_returns_to_the_viewer_screen() {
let mut app = App::new(4.0, 60.0).expect("construct App");
app.screen = AppScreen::Search;
app.search_modal = Some(SearchModal::new(None));
app.diff_viewer.load_diff(&DiffSessionData {
before_path: PathBuf::from("before.rs"),
after_path: PathBuf::from("after.rs"),
before_contents: "foo\nbar\n".to_string(),
after_contents: "foo\nbar\n".to_string(),
before_ranges: Vec::new(),
after_ranges: Vec::new(),
comment_only: false,
plain_text_fallback: false,
});
app.handle_search_submitted("bar".to_string());
assert_eq!(app.screen, AppScreen::Viewer);
assert!(app.search_modal.is_none());
assert_eq!(app.diff_viewer.focused_cursor_position(), Some((1, 0)));
}
#[test]
fn submitting_an_empty_search_repeats_the_last_submitted_query() {
let mut app = App::new(4.0, 60.0).expect("construct App");
app.diff_viewer.load_diff(&DiffSessionData {
before_path: PathBuf::from("before.rs"),
after_path: PathBuf::from("after.rs"),
before_contents: "foo\nbar\n".to_string(),
after_contents: "foo\nbar\n".to_string(),
before_ranges: Vec::new(),
after_ranges: Vec::new(),
comment_only: false,
plain_text_fallback: false,
});
app.handle_search_submitted("bar".to_string());
app.diff_viewer.restore_cursor(Panel::Before, 0, 0);
app.handle_search_submitted(String::new());
assert_eq!(app.last_search_query.as_deref(), Some("bar"));
assert_eq!(app.diff_viewer.focused_cursor_position(), Some((1, 0)));
}
#[test]
fn recent_pairs_are_offered_only_while_no_file_is_loaded() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
app.recent_pairs = vec![(PathBuf::from("old.rs"), PathBuf::from("new.rs"))];
assert!(rendered_text(&draw_viewer_once(&mut app)?).contains("Recent pairs"));
app.before_path = Some(PathBuf::from("old.rs"));
assert!(!rendered_text(&draw_viewer_once(&mut app)?).contains("Recent pairs"));
Ok(())
}
#[test]
fn redelivering_the_opening_keystroke_would_seed_the_search_query_with_a_stray_slash() {
use crossterm::event::{KeyEvent, KeyModifiers};
let mut app = App::new(4.0, 60.0).expect("construct App");
app.search_modal = Some(SearchModal::new(None));
app.screen = AppScreen::Search;
let event = Event::Key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
app.dispatch_event_to_active_screen(event).unwrap();
assert_eq!(
app.search_modal.unwrap().query(),
"/",
"confirms why handle_events must skip re-dispatching the keystroke that just opened \
this screen, not just document it"
);
}
#[test]
fn handle_file_selected_loads_into_the_dialogs_target_panel() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
let before = write_temp_file("before contents");
app.dialog_target = Some(Panel::Before);
app.handle_file_selected(before.path().to_path_buf())?;
assert_eq!(app.before_path, Some(before.path().to_path_buf()));
assert_eq!(app.screen, AppScreen::Viewer);
assert!(app.dialog_target.is_none());
Ok(())
}
#[test]
fn accepting_the_render_options_panel_keeps_what_it_applied() -> Result<()> {
let config = tempfile::NamedTempFile::new().expect("temp config");
unsafe { std::env::set_var(theme::CONFIG_ENV, config.path()) };
let mut app = App::new(4.0, 60.0)?;
app.diff_viewer.set_render_options(RenderOptions::FULL);
app.screen = AppScreen::RenderOptions;
app.render_options_dialog = Some(RenderOptionsDialog::new(RenderOptions::FULL));
app.apply_render_options(RenderOptions::MINIMAL)?;
app.handle_render_options_accepted();
assert_eq!(app.diff_viewer.render_options(), RenderOptions::MINIMAL);
assert!(app.render_options_dialog.is_none());
assert_eq!(app.screen, AppScreen::Viewer);
unsafe { std::env::remove_var(theme::CONFIG_ENV) };
Ok(())
}
#[test]
fn apply_theme_selection_updates_viewer_and_returns_to_the_viewer_screen() {
let config = tempfile::NamedTempFile::new().expect("temp config");
unsafe { std::env::set_var(theme::CONFIG_ENV, config.path()) };
let mut app = App::new(4.0, 60.0).expect("construct App");
app.screen = AppScreen::SelectTheme;
app.theme_dialog = Some(ThemeDialog::new(app.current_theme));
app.apply_theme_selection(OverlayTheme::SolarizedLight);
assert_eq!(app.current_theme, OverlayTheme::SolarizedLight);
assert_eq!(app.screen, AppScreen::Viewer);
assert!(app.theme_dialog.is_none());
unsafe { std::env::remove_var(theme::CONFIG_ENV) };
}
fn rendered_text(terminal: &ratatui::Terminal<ratatui::backend::TestBackend>) -> String {
terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect()
}
#[test]
fn draw_viewer_badges_minimal_options_in_the_footer() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
app.diff_viewer.set_render_options(RenderOptions::MINIMAL);
let backend = ratatui::backend::TestBackend::new(120, 24);
let mut terminal = ratatui::Terminal::new(backend)?;
terminal.draw(|f| {
let area = f.area();
app.draw_viewer(f, area).unwrap();
})?;
assert!(
rendered_text(&terminal).contains("[minimal]"),
"expected the minimal badge in the footer"
);
Ok(())
}
#[test]
fn draw_viewer_does_not_badge_full_options() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
app.diff_viewer.set_render_options(RenderOptions::FULL);
let backend = ratatui::backend::TestBackend::new(120, 24);
let mut terminal = ratatui::Terminal::new(backend)?;
terminal.draw(|f| {
let area = f.area();
app.draw_viewer(f, area).unwrap();
})?;
let text = rendered_text(&terminal);
assert!(!text.contains("[minimal]"), "got: {text}");
assert!(!text.contains("[full]"), "got: {text}");
Ok(())
}
#[test]
fn draw_viewer_badges_a_single_disabled_option_by_name() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
app.diff_viewer.set_render_options(RenderOptions {
leading_whitespace: false,
structural_punctuation: true,
whole_pair_updates: false,
paint_reindent_only_moves: true,
paint_displaced_moves: true,
paint_resized_moves: true,
whole_identifier_updates: true,
});
let backend = ratatui::backend::TestBackend::new(120, 24);
let mut terminal = ratatui::Terminal::new(backend)?;
terminal.draw(|f| {
let area = f.area();
app.draw_viewer(f, area).unwrap();
})?;
let text = rendered_text(&terminal);
assert!(!text.contains("[minimal]"), "got: {text}");
assert!(text.contains("Leading whitespace"), "got: {text}");
Ok(())
}
#[test]
fn draw_viewer_shows_the_diff_summary_status_bar_when_set() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
app.diff_summary = Some(DiffSummary::NewFile);
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend)?;
terminal.draw(|f| {
let area = f.area();
app.draw_viewer(f, area).unwrap();
})?;
assert!(
rendered_text(&terminal).contains(DiffSummary::NewFile.label()),
"expected the New file status bar to be drawn"
);
Ok(())
}
#[test]
fn draw_viewer_shows_no_status_bar_when_diff_summary_is_none() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
assert!(app.diff_summary.is_none());
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend)?;
terminal.draw(|f| {
let area = f.area();
app.draw_viewer(f, area).unwrap();
})?;
let text = rendered_text(&terminal);
for summary in [
DiffSummary::NoChanges,
DiffSummary::NewFile,
DiffSummary::DeletedFile,
DiffSummary::WhitespaceOnly,
DiffSummary::RefactorMovedOnly,
] {
assert!(
!text.contains(summary.label()),
"no summary label should render when diff_summary is None: {}",
summary.label()
);
}
Ok(())
}
fn summary_label_occurrences(
terminal: &ratatui::Terminal<ratatui::backend::TestBackend>,
summary: DiffSummary,
) -> usize {
rendered_text(terminal).matches(summary.label()).count()
}
fn draw_viewer_once(app: &mut App) -> Result<ratatui::Terminal<ratatui::backend::TestBackend>> {
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend)?;
terminal.draw(|f| {
let area = f.area();
app.draw_viewer(f, area).unwrap();
})?;
Ok(terminal)
}
#[test]
fn the_diffing_screen_counts_elapsed_time_in_tenths() {
let rendered = |elapsed: Option<std::time::Duration>| -> String {
let backend = ratatui::backend::TestBackend::new(80, 6);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| {
f.render_widget(diffing_status_paragraph(elapsed), f.area());
})
.unwrap();
rendered_text(&terminal)
};
let text = rendered(Some(std::time::Duration::from_millis(100)));
assert!(text.contains("0.1s"), "expected tenths of a second: {text}");
assert!(text.contains("Esc cancels"), "the cancel hint must survive");
let text = rendered(Some(std::time::Duration::from_millis(2345)));
assert!(text.contains("2.3s"), "expected 2.3s, got: {text}");
let text = rendered(Some(std::time::Duration::from_millis(75_400)));
assert!(text.contains("75.4s"), "expected 75.4s, got: {text}");
}
#[test]
fn the_diffing_screen_omits_the_counter_when_no_diff_is_timed() {
let backend = ratatui::backend::TestBackend::new(80, 6);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| f.render_widget(diffing_status_paragraph(None), f.area()))
.unwrap();
let text = rendered_text(&terminal);
assert!(
text.contains("Diffing"),
"still says what it is doing: {text}"
);
assert!(
!text.contains("0.0s"),
"no counter at all beats a counter stuck at zero: {text}"
);
}
#[test]
fn the_diff_clock_is_cleared_when_the_diff_finishes() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
app.diff_started_at = Some(std::time::Instant::now());
app.handle_diff_ready(&DiffSessionData {
before_path: PathBuf::from("a.rs"),
after_path: PathBuf::from("b.rs"),
before_contents: "fn main() {}\n".to_string(),
after_contents: "fn main() {}\n".to_string(),
before_ranges: Vec::new(),
after_ranges: Vec::new(),
comment_only: false,
plain_text_fallback: false,
});
assert!(
app.diff_started_at.is_none(),
"a finished diff must not leave its clock running"
);
Ok(())
}
#[test]
fn draw_viewer_shows_a_centered_toast_while_the_countdown_runs() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
app.diff_summary = Some(DiffSummary::WhitespaceOnly);
app.summary_toast_ticks = SUMMARY_TOAST_TICKS;
let terminal = draw_viewer_once(&mut app)?;
assert_eq!(
summary_label_occurrences(&terminal, DiffSummary::WhitespaceOnly),
2,
"the label should be drawn twice - once in the status bar, once in the toast"
);
assert!(
rendered_text(&terminal).contains('\u{256d}'),
"the toast should draw its rounded border"
);
Ok(())
}
#[test]
fn summary_toast_fades_on_its_own_but_the_status_bar_stays() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
app.diff_summary = Some(DiffSummary::WhitespaceOnly);
app.summary_toast_ticks = SUMMARY_TOAST_TICKS;
for _ in 0..SUMMARY_TOAST_TICKS {
app.tick_summary_toast();
}
assert_eq!(app.summary_toast_ticks, 0);
let terminal = draw_viewer_once(&mut app)?;
assert_eq!(
summary_label_occurrences(&terminal, DiffSummary::WhitespaceOnly),
1,
"once the toast fades the status bar should still report the summary"
);
assert!(
!rendered_text(&terminal).contains('\u{256d}'),
"the toast border should be gone"
);
Ok(())
}
#[test]
fn summary_toast_is_dismissed_without_waiting_for_the_countdown() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
app.diff_summary = Some(DiffSummary::CommentOnly);
app.summary_toast_ticks = SUMMARY_TOAST_TICKS;
app.dismiss_summary_toast();
let terminal = draw_viewer_once(&mut app)?;
assert_eq!(
summary_label_occurrences(&terminal, DiffSummary::CommentOnly),
1,
"a dismissed toast should leave only the status bar"
);
Ok(())
}
#[test]
fn no_toast_is_drawn_when_the_diff_has_no_summary_worth_reporting() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
app.diff_summary = None;
app.summary_toast_ticks = SUMMARY_TOAST_TICKS;
let terminal = draw_viewer_once(&mut app)?;
assert!(
!rendered_text(&terminal).contains('\u{256d}'),
"no toast should be drawn without a summary"
);
Ok(())
}
#[test]
fn diff_ready_starts_the_toast_countdown() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
assert_eq!(app.summary_toast_ticks, 0);
app.handle_diff_ready(&DiffSessionData {
before_path: PathBuf::from("a.rs"),
after_path: PathBuf::from("b.rs"),
before_contents: "fn main() {}\n".to_string(),
after_contents: "fn main() {}\n".to_string(),
before_ranges: Vec::new(),
after_ranges: Vec::new(),
comment_only: false,
plain_text_fallback: false,
});
assert_eq!(app.diff_summary, Some(DiffSummary::NoChanges));
assert_eq!(
app.summary_toast_ticks, SUMMARY_TOAST_TICKS,
"a fresh summary should raise the toast"
);
Ok(())
}
#[test]
fn summary_toast_area_is_centered_and_clamped_to_a_narrow_terminal() {
let summary = DiffSummary::WhitespaceOnly;
let area = Rect::new(0, 0, 80, 24);
let popup = summary_toast_area(summary, area);
assert_eq!(popup.height, 3);
assert_eq!(popup.width, summary.label().chars().count() as u16 + 4);
let right_margin = area.width - popup.width - popup.x;
assert!(
popup.x.abs_diff(right_margin) <= 1,
"expected a centered popup, got x={} right={}",
popup.x,
right_margin
);
let narrow = summary_toast_area(summary, Rect::new(0, 0, 10, 2));
assert_eq!((narrow.width, narrow.height), (10, 2));
assert_eq!((narrow.x, narrow.y), (0, 0));
}
#[test]
fn draw_viewer_always_shows_the_footer_key_hints() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
assert!(app.diff_summary.is_none());
assert!(app.last_error.is_none());
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend)?;
terminal.draw(|f| {
let area = f.area();
app.draw_viewer(f, area).unwrap();
})?;
assert!(
rendered_text(&terminal).contains("?:help"),
"expected the footer's key hints to be drawn even with no status bar or error banner"
);
Ok(())
}
#[test]
fn the_options_badge_names_what_differs_from_full() {
let full = RenderOptions::FULL;
assert_eq!(render_options_badge(full), None);
assert_eq!(
render_options_badge(RenderOptions::MINIMAL).as_deref(),
Some("[minimal]")
);
let no_leading_whitespace = RenderOptions {
leading_whitespace: false,
..full
};
assert_eq!(
render_options_badge(no_leading_whitespace).as_deref(),
Some("[Leading whitespace off]")
);
let whole_pairs = RenderOptions {
whole_pair_updates: true,
..full
};
assert_eq!(
render_options_badge(whole_pairs).as_deref(),
Some("[Whole-pair updates on]")
);
assert_eq!(
render_options_badge(RenderOptions {
whole_pair_updates: true,
..no_leading_whitespace
})
.as_deref(),
Some("[Leading whitespace off; Whole-pair updates on]")
);
}
#[test]
fn format_change_counts_omits_zero_categories() {
assert_eq!(
format_change_counts(ChangeCounts {
insertions: 12,
deletions: 4,
updates: 2,
moves: 3,
}),
"+12 -4 ~2 M3"
);
assert_eq!(
format_change_counts(ChangeCounts {
insertions: 3,
deletions: 0,
updates: 0,
moves: 0,
}),
"+3"
);
assert_eq!(
format_change_counts(ChangeCounts {
insertions: 0,
deletions: 0,
updates: 0,
moves: 0,
}),
""
);
}
#[test]
fn draw_viewer_shows_change_counts_in_the_footer_when_set() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
app.change_counts = Some(ChangeCounts {
insertions: 12,
deletions: 4,
updates: 2,
moves: 0,
});
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend)?;
terminal.draw(|f| {
let area = f.area();
app.draw_viewer(f, area).unwrap();
})?;
assert!(
rendered_text(&terminal).contains("+12 -4 ~2"),
"expected the change counts to be drawn in the footer"
);
Ok(())
}
#[test]
fn draw_viewer_shows_change_progress_in_the_footer_once_the_focused_panel_has_changes()
-> Result<()> {
let mut app = App::new(4.0, 60.0)?;
app.diff_viewer.load_diff(&DiffSessionData {
before_path: PathBuf::from("before.rs"),
after_path: PathBuf::from("after.rs"),
before_contents: "a\nb\nc".to_string(),
after_contents: "a\nb\nc".to_string(),
before_ranges: vec![crate::diff::text::RangeMatch {
source: crate::diff::text_range::TextRange::new(1, 0, 1, 1),
destination: crate::diff::text_range::TextRange::new(1, 0, 1, 1),
operation: crate::diff::text::TextOperation::Update,
}],
after_ranges: vec![crate::diff::text::RangeMatch {
source: crate::diff::text_range::TextRange::new(1, 0, 1, 1),
destination: crate::diff::text_range::TextRange::new(1, 0, 1, 1),
operation: crate::diff::text::TextOperation::Update,
}],
comment_only: false,
plain_text_fallback: false,
});
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend)?;
terminal.draw(|f| {
let area = f.area();
app.draw_viewer(f, area).unwrap();
})?;
assert!(
rendered_text(&terminal).contains("change 1/1"),
"expected the footer to show change progress once the focused panel has a change"
);
Ok(())
}
#[test]
fn draw_viewer_shows_search_match_progress_in_the_footer_in_place_of_change_progress()
-> Result<()> {
let mut app = App::new(4.0, 60.0)?;
app.diff_viewer.load_diff(&DiffSessionData {
before_path: PathBuf::from("before.rs"),
after_path: PathBuf::from("after.rs"),
before_contents: "foo\nbar\nfoo bar\n".to_string(),
after_contents: "foo\nbar\nfoo bar\n".to_string(),
before_ranges: vec![crate::diff::text::RangeMatch {
source: crate::diff::text_range::TextRange::new(1, 0, 1, 1),
destination: crate::diff::text_range::TextRange::new(1, 0, 1, 1),
operation: crate::diff::text::TextOperation::Update,
}],
after_ranges: vec![crate::diff::text::RangeMatch {
source: crate::diff::text_range::TextRange::new(1, 0, 1, 1),
destination: crate::diff::text_range::TextRange::new(1, 0, 1, 1),
operation: crate::diff::text::TextOperation::Update,
}],
comment_only: false,
plain_text_fallback: false,
});
app.diff_viewer.search("bar");
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend)?;
terminal.draw(|f| {
let area = f.area();
app.draw_viewer(f, area).unwrap();
})?;
let text = rendered_text(&terminal);
assert!(
text.contains("match 1/2"),
"expected the footer to show search-match progress"
);
assert!(
!text.contains("change 1/"),
"change progress must be replaced, not shown alongside, search-match progress"
);
Ok(())
}
#[test]
fn draw_viewer_shows_plain_text_fallback_in_the_footer() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
app.plain_text_fallback = true;
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend)?;
terminal.draw(|f| {
let area = f.area();
app.draw_viewer(f, area).unwrap();
})?;
let text = rendered_text(&terminal);
assert!(
text.contains("[plain text]"),
"expected the plain-text fallback indicator to be drawn in the footer"
);
Ok(())
}
#[test]
fn draw_viewer_shows_both_the_status_bar_and_the_error_banner_at_once() -> Result<()> {
let mut app = App::new(4.0, 60.0)?;
app.diff_summary = Some(DiffSummary::RefactorMovedOnly);
app.last_error = Some("unsupported file type".to_string());
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend)?;
terminal.draw(|f| {
let area = f.area();
app.draw_viewer(f, area).unwrap();
})?;
let text = rendered_text(&terminal);
assert!(text.contains(DiffSummary::RefactorMovedOnly.label()));
assert!(text.contains("unsupported file type"));
Ok(())
}
#[test]
fn diff_ready_summary_matches_summarize_diff_on_the_same_session_data() {
let data = DiffSessionData {
before_path: PathBuf::from("before.rs"),
after_path: PathBuf::from("after.rs"),
before_contents: String::new(),
after_contents: "fn main() {}".to_string(),
before_ranges: vec![],
after_ranges: vec![crate::diff::text::RangeMatch {
source: crate::diff::text_range::TextRange::new(0, 0, 1, 0),
destination: crate::diff::text_range::TextRange::new(0, 0, 1, 0),
operation: crate::diff::text::TextOperation::Insert,
}],
comment_only: false,
plain_text_fallback: false,
};
let summary = summarize_diff_with_comment_check(
&data.before_contents,
&data.after_contents,
&data.before_ranges,
&data.after_ranges,
data.comment_only,
);
assert_eq!(summary, Some(DiffSummary::NewFile));
}
#[test]
fn diff_ready_summary_reports_comment_only_when_the_session_data_says_so() {
let ranges = vec![crate::diff::text::RangeMatch {
source: crate::diff::text_range::TextRange::new(0, 0, 1, 0),
destination: crate::diff::text_range::TextRange::new(0, 0, 1, 0),
operation: crate::diff::text::TextOperation::Update,
}];
let data = DiffSessionData {
before_path: PathBuf::from("before.rs"),
after_path: PathBuf::from("after.rs"),
before_contents: "// old comment".to_string(),
after_contents: "// new comment".to_string(),
before_ranges: ranges.clone(),
after_ranges: ranges,
comment_only: true,
plain_text_fallback: false,
};
let summary = summarize_diff_with_comment_check(
&data.before_contents,
&data.after_contents,
&data.before_ranges,
&data.after_ranges,
data.comment_only,
);
assert_eq!(summary, Some(DiffSummary::CommentOnly));
}
#[test]
fn compute_diff_reports_comment_only_for_a_real_inserted_comment() -> Result<()> {
let before = tempfile::Builder::new()
.suffix(".rs")
.tempfile()
.expect("create temp file");
std::fs::write(before.path(), "fn main() {}\n").expect("write temp file");
let after = tempfile::Builder::new()
.suffix(".rs")
.tempfile()
.expect("create temp file");
std::fs::write(after.path(), "// a comment\nfn main() {}\n").expect("write temp file");
let (data, _large_residual) = compute_diff(before.path(), after.path())?;
assert!(
data.comment_only,
"inserting only a comment should set comment_only"
);
let summary = summarize_diff_with_comment_check(
&data.before_contents,
&data.after_contents,
&data.before_ranges,
&data.after_ranges,
data.comment_only,
);
assert_eq!(summary, Some(DiffSummary::CommentOnly));
Ok(())
}
#[test]
fn a_tab_indented_file_keeps_its_tabs_and_the_viewer_draws_them_as_spaces() -> Result<()> {
let before = Path::new(
"src/test/data/diffs/small/html-gohugoio-hugo-enclose-table-with-div-and-add-thead-tbody/before.html.test",
);
let after = Path::new(
"src/test/data/diffs/small/html-gohugoio-hugo-enclose-table-with-div-and-add-thead-tbody/after.html.test",
);
let before_source = std::fs::read_to_string(before)?;
let first_tabbed_row = before_source
.lines()
.position(|line| line.starts_with('\t'))
.expect("the fixture must actually contain a tab for this test to mean anything");
let (data, _large_residual) = compute_diff(before, after)?;
assert_eq!(data.before_contents, before_source);
let mut viewer = DiffViewer::new();
viewer.load_diff(&data);
viewer.jump_to_line(first_tabbed_row + 1);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(240, 40))?;
terminal.draw(|frame| {
let area = frame.area();
viewer.draw(frame, area).unwrap();
})?;
let text: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(!text.contains('\t'), "a raw tab reached the buffer");
let expanded = crate::tui::display_columns::expand_tabs(
before_source.lines().nth(first_tabbed_row).unwrap(),
);
assert!(
text.contains(expanded.trim_end()),
"row {first_tabbed_row} should be drawn as {expanded:?}"
);
Ok(())
}
#[test]
fn compute_diff_leaves_a_crlf_file_offset_stable_and_free_of_other_control_bytes() -> Result<()>
{
let before = Path::new(
"src/test/data/diffs/small/typescript-microsoft-typescript-add-target-comment/before.ts.test",
);
let after = Path::new(
"src/test/data/diffs/small/typescript-microsoft-typescript-add-target-comment/after.ts.test",
);
let before_source = std::fs::read_to_string(before)?;
assert!(before_source.contains('\r'));
let (data, _large_residual) = compute_diff(before, after)?;
for contents in [&data.before_contents, &data.after_contents] {
assert!(
!contents
.as_bytes()
.windows(2)
.any(|pair| pair[0] == b'\r' && pair[1] != b'\n'),
"a carriage return that is not a CRLF terminator survived"
);
assert!(
!contents
.chars()
.any(|c| c.is_ascii_control() && !matches!(c, '\t' | '\r' | '\n')),
"a control character other than a tab or line terminator survived"
);
}
assert_eq!(
(
data.before_contents.len(),
data.before_contents.split('\n').count()
),
(before_source.len(), before_source.split('\n').count()),
"the substitution must preserve every byte offset and every row boundary"
);
Ok(())
}
#[test]
fn display_safe_leaves_multi_byte_control_code_points_alone() {
let text = "a\u{9c}b\x07\n";
let safe = display_safe(text);
assert_eq!(safe, "a\u{9c}b \n");
assert_eq!(safe.len(), text.len());
}
#[test]
fn display_safe_keeps_tabs_for_the_renderers_to_expand() {
assert_eq!(display_safe("\tx\x0b\ty\n"), "\tx \ty\n");
}
#[test]
fn display_safe_keeps_a_crlf_terminator_and_substitutes_a_lone_carriage_return() {
assert_eq!(display_safe("a\r\nb\rc\n"), "a\r\nb c\n");
assert_eq!(display_safe("trailing\r"), "trailing ");
}
}