shuire 0.1.1

Vim-like TUI git diff viewer
use std::sync::{
    Arc,
    atomic::{AtomicBool, Ordering},
};

use crossterm::event::KeyEvent;
use ratatui::prelude::Rect;
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use tracing::{debug, info};

use crate::{
    action::Action,
    cli::DiffRange,
    components::{Component, diff_view::DiffView, toast::Toast},
    config::Config,
    state::App as DiffApp,
    tui::{Event, Tui},
};

pub struct App {
    config: Config,
    tick_rate: f64,
    frame_rate: f64,
    diff_view: DiffView,
    toast: Toast,
    should_quit: bool,
    should_suspend: bool,
    mode: Mode,
    last_tick_key_events: Vec<KeyEvent>,
    action_tx: mpsc::UnboundedSender<Action>,
    action_rx: mpsc::UnboundedReceiver<Action>,
    files_changed: Arc<AtomicBool>,
    _watcher: Option<notify::RecommendedWatcher>,
}

#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Mode {
    #[default]
    Home,
}

impl App {
    pub fn new(
        tick_rate: f64,
        frame_rate: f64,
        state: DiffApp,
        config: Config,
    ) -> color_eyre::Result<Self> {
        let (action_tx, action_rx) = mpsc::unbounded_channel();
        let files_changed = Arc::new(AtomicBool::new(false));
        let watcher = setup_file_watcher(&state.range, Arc::clone(&files_changed));
        let toast = Toast::new(state.theme_name, state.color_overrides.clone());
        Ok(Self {
            tick_rate,
            frame_rate,
            diff_view: DiffView::new(state),
            toast,
            should_quit: false,
            should_suspend: false,
            config,
            mode: Mode::Home,
            last_tick_key_events: Vec::new(),
            action_tx,
            action_rx,
            files_changed,
            _watcher: watcher,
        })
    }

    pub fn into_diff_state(self) -> DiffApp {
        self.diff_view.state
    }

    pub async fn run(&mut self) -> color_eyre::Result<()> {
        let mut tui = Tui::new()?
            .tick_rate(self.tick_rate)
            .frame_rate(self.frame_rate);
        tui.enter()?;

        self.diff_view
            .register_action_handler(self.action_tx.clone())?;
        self.diff_view
            .register_config_handler(self.config.clone())?;
        self.diff_view.init(tui.size()?)?;
        self.toast.register_action_handler(self.action_tx.clone())?;
        self.toast.init(tui.size()?)?;

        let action_tx = self.action_tx.clone();
        loop {
            self.handle_events(&mut tui).await?;
            self.handle_actions(&mut tui)?;
            if self.should_suspend {
                tui.suspend()?;
                action_tx.send(Action::Resume)?;
                action_tx.send(Action::ClearScreen)?;
                tui.enter()?;
            } else if self.should_quit {
                tui.stop()?;
                break;
            }
        }
        tui.exit()?;
        Ok(())
    }

    async fn handle_events(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
        let Some(event) = tui.next_event().await else {
            return Ok(());
        };
        let action_tx = self.action_tx.clone();
        match event {
            Event::Quit => action_tx.send(Action::Quit)?,
            Event::Tick => {
                action_tx.send(Action::Tick)?;
                if self.files_changed.swap(false, Ordering::Relaxed) {
                    action_tx.send(Action::Flash(
                        "Files changed - press R to reload".to_string(),
                    ))?;
                }
            }
            Event::Render => action_tx.send(Action::Render)?,
            Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
            Event::Key(key) => self.handle_key_event(key)?,
            _ => {}
        }
        if let Some(action) = self.diff_view.handle_events(Some(event.clone()))? {
            action_tx.send(action)?;
        }
        if let Some(action) = self.toast.handle_events(Some(event))? {
            action_tx.send(action)?;
        }
        Ok(())
    }

    fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
        let action_tx = self.action_tx.clone();
        let Some(keymap) = self.config.keybindings.0.get(&self.mode) else {
            return Ok(());
        };
        match keymap.get(&vec![key]) {
            Some(action) => {
                info!("Got action: {action:?}");
                action_tx.send(action.clone())?;
            }
            _ => {
                self.last_tick_key_events.push(key);
                if let Some(action) = keymap.get(&self.last_tick_key_events) {
                    info!("Got action: {action:?}");
                    action_tx.send(action.clone())?;
                }
            }
        }
        Ok(())
    }

    fn handle_actions(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
        while let Ok(action) = self.action_rx.try_recv() {
            if action != Action::Tick && action != Action::Render {
                debug!("{action:?}");
            }
            match action {
                Action::Tick => {
                    self.last_tick_key_events.drain(..);
                }
                Action::Quit => self.should_quit = true,
                Action::Suspend => self.should_suspend = true,
                Action::Resume => self.should_suspend = false,
                Action::ClearScreen => tui.terminal.clear()?,
                Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
                Action::Render => self.render(tui)?,
                Action::Reload => self.reload_diff(),
                Action::OpenEditor => self.open_editor(tui)?,
                _ => {}
            }
            if let Some(action) = self.diff_view.update(action.clone())? {
                self.action_tx.send(action)?;
            }
            if let Some(action) = self.toast.update(action.clone())? {
                self.action_tx.send(action)?;
            }
        }
        Ok(())
    }

    fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> color_eyre::Result<()> {
        tui.resize(Rect::new(0, 0, w, h))?;
        self.render(tui)?;
        Ok(())
    }

    fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
        self.toast.set_theme(
            self.diff_view.state.theme_name,
            self.diff_view.state.color_overrides.clone(),
        );
        tui.draw(|frame| {
            if let Err(err) = self.diff_view.draw(frame, frame.area()) {
                let _ = self
                    .action_tx
                    .send(Action::Error(format!("Failed to draw: {:?}", err)));
            }
            if let Err(err) = self.toast.draw(frame, frame.area()) {
                let _ = self
                    .action_tx
                    .send(Action::Error(format!("Failed to draw: {:?}", err)));
            }
        })?;
        Ok(())
    }

    fn reload_diff(&mut self) {
        let state = &mut self.diff_view.state;
        match crate::git::load_diff_simple(&state.range) {
            Ok(mut files) => {
                crate::highlight::highlight_files(&mut files);
                state.diff_fingerprint = crate::storage::diff_fingerprint(&files);
                state.files = files;
                state.selected = state.selected.min(state.files.len().saturating_sub(1));
                state.cursor_line = state.first_navigable_line();
                state.diff_scroll = 0;
                state.comment_focus = None;
                state.set_flash("Reloaded".to_string());
            }
            Err(e) => {
                state.set_flash(format!("Reload failed: {e}"));
            }
        }
    }

    fn open_editor(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
        let path = self.diff_view.state.current_path();
        let lineno = self.diff_view.state.current_lineno();
        if path.is_empty() {
            return Ok(());
        }
        let editor = std::env::var("SHUIRE_EDITOR")
            .or_else(|_| std::env::var("EDITOR"))
            .unwrap_or_else(|_| "vi".to_string());
        tui.exit()?;
        let _ = if editor.contains("code") || editor.contains("cursor") {
            std::process::Command::new(&editor)
                .arg("--goto")
                .arg(format!("{path}:{lineno}"))
                .status()
        } else {
            std::process::Command::new(&editor)
                .arg(format!("+{lineno}"))
                .arg(&path)
                .status()
        };
        tui.enter()?;
        self.action_tx.send(Action::ClearScreen)?;
        Ok(())
    }
}

fn setup_file_watcher(
    range: &DiffRange,
    changed_flag: Arc<AtomicBool>,
) -> Option<notify::RecommendedWatcher> {
    use notify::{RecursiveMode, Watcher};
    if !matches!(
        range,
        DiffRange::Working
            | DiffRange::Staged
            | DiffRange::StagedAgainst { .. }
            | DiffRange::Uncommitted
            | DiffRange::UncommittedAgainst { .. }
    ) {
        return None;
    }
    let cwd = std::env::current_dir().ok()?;
    const IGNORED_DIRS: &[&str] = &[".git", "target", "node_modules", "dist", "build", ".next"];
    let mut watcher =
        notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
            if let Ok(ev) = res {
                let dominated_by_ignored = ev.paths.iter().all(|p| {
                    p.components()
                        .any(|c| IGNORED_DIRS.contains(&c.as_os_str().to_str().unwrap_or("")))
                });
                if !dominated_by_ignored {
                    changed_flag.store(true, Ordering::Relaxed);
                }
            }
        })
        .ok()?;
    watcher.watch(&cwd, RecursiveMode::Recursive).ok()?;
    Some(watcher)
}