omnidiff 0.2.0

Fast, robust, syntax-aware code diffing using tree-sitter ASTs
Documentation
/*  This file is part of the OmniDiff code diffing tool.
 *
 *  Copyright (C) 2026 Marko Ivankovic
 *
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU Affero General Public License as published
 *  by the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU Affero General Public License for more details.
 *
 *  You should have received a copy of the GNU Affero General Public License
 *  along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
    Frame,
    layout::{Constraint, Direction, Layout, Rect},
    prelude::Stylize,
    style::{Color, Style},
    symbols::border,
    text::Line,
    widgets::{Block, Borders, Paragraph},
};

use super::Component;
use crate::tui::actions::Action;

/// The `/` search prompt. Every keystroke emits `Action::SearchQueryChanged`; `App` previews the
/// highlights and feeds the count back through `set_live_match_count`, so the title shows live
/// `N matches` before anything is submitted.
#[derive(Default)]
pub struct SearchModal {
    query: String,
    /// Named in the hint line: `App` re-submits it on a bare Enter.
    last_query: Option<String>,
    /// `None` until the first keystroke.
    live_match_count: Option<usize>,
}

impl SearchModal {
    pub fn new(last_query: Option<String>) -> Self {
        Self {
            last_query,
            ..Self::default()
        }
    }

    pub fn set_live_match_count(&mut self, count: usize) {
        self.live_match_count = Some(count);
    }

    /// For `app.rs`'s tests only.
    #[cfg(test)]
    pub(crate) fn query(&self) -> &str {
        &self.query
    }

    pub fn popup_area(&self, area: Rect) -> Rect {
        let width = 50.min(area.width);
        let height = 4.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)
    }

    /// The on-screen cursor position, for the same `area` passed to `draw`.
    pub fn cursor_screen_position(&self, area: Rect) -> (u16, u16) {
        // +1 for the border, +1 for the leading "/" the input line is prefixed with.
        let col = area.x + 2 + self.query.chars().count() as u16;
        (col, area.y + 1)
    }
}

impl Component for SearchModal {
    fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
        match key.code {
            KeyCode::Char(c) => {
                self.query.push(c);
                Ok(Some(Action::SearchQueryChanged(self.query.clone())))
            }
            KeyCode::Backspace => {
                self.query.pop();
                Ok(Some(Action::SearchQueryChanged(self.query.clone())))
            }
            KeyCode::Enter => Ok(Some(Action::SearchSubmitted(self.query.clone()))),
            KeyCode::Esc => Ok(Some(Action::DialogCancelled)),
            _ => Ok(None),
        }
    }

    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
        let layout = Layout::default()
            .direction(Direction::Vertical)
            .constraints([Constraint::Min(1), Constraint::Length(1)])
            .split(area);

        let title = match self.live_match_count {
            Some(1) => " Search - 1 match ".to_string(),
            Some(n) => format!(" Search - {n} matches "),
            None => " Search ".to_string(),
        };
        let block = Block::default()
            .title(title.bold().fg(Color::Cyan))
            .borders(Borders::ALL)
            .border_set(border::ROUNDED)
            .border_style(Style::new().fg(Color::Cyan));

        frame.render_widget(
            Paragraph::new(format!("/{}", self.query)).block(block),
            layout[0],
        );
        let hint = match &self.last_query {
            Some(last) if self.query.is_empty() => {
                format!(" Enter: repeat '{last}' | Esc: cancel ")
            }
            _ => " Enter: jump to match | Esc: cancel ".to_string(),
        };
        frame.render_widget(Line::from(hint).dim(), layout[1]);

        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crossterm::event::KeyModifiers;

    fn key(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, KeyModifiers::NONE)
    }

    #[test]
    fn typing_characters_builds_up_the_query() {
        let mut modal = SearchModal::new(None);
        modal.handle_key_event(key(KeyCode::Char('f'))).unwrap();
        modal.handle_key_event(key(KeyCode::Char('o'))).unwrap();
        modal.handle_key_event(key(KeyCode::Char('o'))).unwrap();
        assert_eq!(modal.query, "foo");
    }

    #[test]
    fn backspace_removes_the_last_character() {
        let mut modal = SearchModal::new(None);
        modal.handle_key_event(key(KeyCode::Char('f'))).unwrap();
        modal.handle_key_event(key(KeyCode::Char('o'))).unwrap();
        modal.handle_key_event(key(KeyCode::Backspace)).unwrap();
        assert_eq!(modal.query, "f");
    }

    #[test]
    fn backspace_on_an_empty_query_is_a_no_op() {
        let mut modal = SearchModal::new(None);
        modal.handle_key_event(key(KeyCode::Backspace)).unwrap();
        assert_eq!(modal.query, "");
    }

    #[test]
    fn enter_submits_the_typed_query() {
        let mut modal = SearchModal::new(None);
        modal.handle_key_event(key(KeyCode::Char('x'))).unwrap();
        let action = modal.handle_key_event(key(KeyCode::Enter)).unwrap();
        assert_eq!(action, Some(Action::SearchSubmitted("x".to_string())));
    }

    #[test]
    fn esc_cancels_without_submitting() {
        let mut modal = SearchModal::new(None);
        let action = modal.handle_key_event(key(KeyCode::Esc)).unwrap();
        assert_eq!(action, Some(Action::DialogCancelled));
    }

    #[test]
    fn cursor_screen_position_follows_the_typed_query() {
        let modal = SearchModal::new(None);
        let area = Rect::new(10, 5, 50, 4);
        assert_eq!(modal.cursor_screen_position(area), (12, 6));

        let mut modal = SearchModal::new(None);
        modal.handle_key_event(key(KeyCode::Char('a'))).unwrap();
        modal.handle_key_event(key(KeyCode::Char('b'))).unwrap();
        assert_eq!(modal.cursor_screen_position(area), (14, 6));
    }
}