jufu 0.1.0

Jujutsu log viewer TUI inspired by keifu
use std::collections::HashSet;

use crossterm::event::{Event, KeyCode, KeyEventKind, MouseEventKind};
use ratatui::layout::Rect;
use tokio::sync::mpsc::UnboundedSender;

use crate::jj;
use crate::model::{AppConfig, DiffCache, Focus, LoadState, LogEntry};

pub type CommandSender = UnboundedSender<BackgroundEvent>;

#[derive(Debug)]
pub enum BackgroundEvent {
    LogsLoaded(anyhow::Result<Vec<LogEntry>>),
    DiffLoaded {
        change_id: String,
        result: anyhow::Result<Vec<String>>,
    },
}

#[derive(Debug, Eq, PartialEq)]
pub enum ControlFlow {
    Continue,
    Exit,
}

pub struct App {
    config: AppConfig,
    pub logs_state: LoadState<Vec<LogEntry>>,
    pub selected: usize,
    pub log_top: usize,
    pub focus: Focus,
    pub diff_scroll: u16,
    pub diff_cache: DiffCache,
    inflight_diffs: HashSet<String>,
    pub left_height: u16,
}

impl App {
    pub fn new(config: AppConfig) -> Self {
        Self {
            config,
            logs_state: LoadState::Loading,
            selected: 0,
            log_top: 0,
            focus: Focus::Log,
            diff_scroll: 0,
            diff_cache: DiffCache::default(),
            inflight_diffs: HashSet::new(),
            left_height: 0,
        }
    }

    pub fn config(&self) -> &AppConfig {
        &self.config
    }

    pub fn selected_log(&self) -> Option<&LogEntry> {
        match &self.logs_state {
            LoadState::Ready(logs) => logs.get(self.selected),
            _ => None,
        }
    }

    pub fn handle_event(&mut self, event: Event, sender: &CommandSender) -> ControlFlow {
        match event {
            Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
                KeyCode::Char('q') | KeyCode::Char('c')
                    if key
                        .modifiers
                        .contains(crossterm::event::KeyModifiers::CONTROL) =>
                {
                    ControlFlow::Exit
                }
                KeyCode::Char('q') => ControlFlow::Exit,
                KeyCode::Up => {
                    self.scroll_up(sender);
                    ControlFlow::Continue
                }
                KeyCode::Down => {
                    self.scroll_down(sender);
                    ControlFlow::Continue
                }
                KeyCode::Enter => {
                    self.toggle_focus();
                    ControlFlow::Continue
                }
                _ => ControlFlow::Continue,
            },
            Event::Mouse(mouse) => {
                match mouse.kind {
                    MouseEventKind::ScrollUp => self.scroll_up(sender),
                    MouseEventKind::ScrollDown => self.scroll_down(sender),
                    _ => {}
                }
                ControlFlow::Continue
            }
            _ => ControlFlow::Continue,
        }
    }

    pub fn apply_background_event(&mut self, event: BackgroundEvent, sender: &CommandSender) {
        match event {
            BackgroundEvent::LogsLoaded(result) => match result {
                Ok(logs) => {
                    self.logs_state = LoadState::Ready(logs);
                    self.selected = 0;
                    self.log_top = 0;
                    self.diff_scroll = 0;
                    self.ensure_selected_diff_loaded(sender);
                }
                Err(error) => {
                    self.logs_state = LoadState::Error(format_error(&error));
                }
            },
            BackgroundEvent::DiffLoaded { change_id, result } => {
                self.inflight_diffs.remove(&change_id);
                match result {
                    Ok(lines) => self.diff_cache.insert(change_id, LoadState::Ready(lines)),
                    Err(error) => self
                        .diff_cache
                        .insert(change_id, LoadState::Error(format_error(&error))),
                }
            }
        }
    }

    pub fn set_left_height(&mut self, area: Rect) {
        self.left_height = area.height;
        self.ensure_selected_visible();
    }

    pub fn current_diff_state(&self) -> Option<&LoadState<Vec<String>>> {
        let change_id = self.selected_log()?.change_id.as_str();
        self.diff_cache.get(change_id)
    }

    fn scroll_up(&mut self, sender: &CommandSender) {
        match self.focus {
            Focus::Log => {
                if self.selected > 0 {
                    self.selected -= 1;
                    self.diff_scroll = 0;
                    self.ensure_selected_visible();
                    self.ensure_selected_diff_loaded(sender);
                }
            }
            Focus::Diff => {
                self.diff_scroll = self.diff_scroll.saturating_sub(1);
            }
        }
    }

    fn scroll_down(&mut self, sender: &CommandSender) {
        match self.focus {
            Focus::Log => {
                if let LoadState::Ready(logs) = &self.logs_state
                    && self.selected + 1 < logs.len()
                {
                    self.selected += 1;
                    self.diff_scroll = 0;
                    self.ensure_selected_visible();
                    self.ensure_selected_diff_loaded(sender);
                }
            }
            Focus::Diff => {
                self.diff_scroll = self.diff_scroll.saturating_add(1);
            }
        }
    }

    fn toggle_focus(&mut self) {
        self.focus = match self.focus {
            Focus::Log => Focus::Diff,
            Focus::Diff => Focus::Log,
        };
    }

    fn ensure_selected_diff_loaded(&mut self, sender: &CommandSender) {
        let Some(entry) = self.selected_log() else {
            return;
        };
        let change_id = entry.change_id.clone();
        if self.diff_cache.get(&change_id).is_some() || self.inflight_diffs.contains(&change_id) {
            return;
        }

        self.diff_cache
            .insert(change_id.clone(), LoadState::Loading);
        self.inflight_diffs.insert(change_id.clone());

        let tx = sender.clone();
        let repo_path = self.config.repo_path.clone();
        tokio::spawn(async move {
            let result = jj::load_diff_stat(&repo_path, &change_id).await;
            let _ = tx.send(BackgroundEvent::DiffLoaded { change_id, result });
        });
    }

    fn ensure_selected_visible(&mut self) {
        let LoadState::Ready(logs) = &self.logs_state else {
            return;
        };
        if logs.is_empty() || self.left_height == 0 {
            return;
        }
        if self.selected < self.log_top {
            self.log_top = self.selected;
        }

        let available_height = self.left_height.saturating_sub(2) as usize;
        if available_height == 0 {
            return;
        }

        while total_block_height(logs, self.log_top, self.selected) > available_height {
            self.log_top += 1;
        }
    }
}

fn total_block_height(logs: &[LogEntry], start: usize, end: usize) -> usize {
    logs[start..=end]
        .iter()
        .map(|entry| entry.graph_lines.len().max(1))
        .sum()
}

fn format_error(error: &anyhow::Error) -> String {
    error
        .chain()
        .map(ToString::to_string)
        .collect::<Vec<_>>()
        .join("\ncaused by: ")
}

#[cfg(test)]
mod tests {
    use crate::model::LogEntry;

    use super::total_block_height;

    #[test]
    fn block_height_counts_multiline_entries() {
        let logs = vec![
            LogEntry {
                graph_lines: vec!["@  ".to_string()],
                ..LogEntry::default()
            },
            LogEntry {
                graph_lines: vec!["".to_string(), "".to_string(), "~".to_string()],
                ..LogEntry::default()
            },
        ];

        assert_eq!(total_block_height(&logs, 0, 1), 4);
    }
}