gmap 0.3.3

Git repository analysis tool for churn and heatmap visualization
Documentation
use std::io;
use std::time::Duration;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use crossterm::event::{
    poll, read, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind,
    MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::{
    backend::CrosstermBackend,
    layout::{Constraint, Direction, Layout},
    style::{Color, Modifier, Style},
    widgets::{Block, Borders, Tabs},
    Terminal,
};

use super::state::{TuiState, ViewMode};
use super::input::{apply_search_filter, ensure_selection_in_filtered, copy_to_clipboard};
use super::views::{
    draw_commit_details_view, draw_help_overlay, draw_heatmap_view,
    draw_statistics_view, draw_timeline_view, draw_file_modal,
};

use crate::cli::CommonArgs;
use crate::git::GitRepo;
use crate::cache::Cache;
use crate::heat::{aggregate_weeks, fetch_commit_stats, load_commit_details};

pub fn run(common: &CommonArgs, path: Option<String>) -> io::Result<()> {
    let repo = GitRepo::open(common.repo.as_ref()).map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
    let mut cache = Cache::new(common.cache.as_deref(), repo.path())
        .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
    let range = repo
        .resolve_range(common.since.as_deref(), common.until.as_deref())
        .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
    let stats = fetch_commit_stats(&repo, &mut cache, &range, common.include_merges, common.binary)
        .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
    let weeks = aggregate_weeks(&stats, &cache, path.as_deref());

    enable_raw_mode()?;
    crossterm::execute!(io::stdout(), EnableMouseCapture)?;

    let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
    let mut state = TuiState::default();
    state.filtered_indices = (0..weeks.len()).collect();
    terminal.clear()?;

    loop {
        if let Err(e) = terminal.draw(|f| {
            let size = f.size();

            if state.show_help {
                draw_help_overlay(f, size);
                return;
            }

            if state.show_file_modal {
                let chunks = Layout::default()
                    .direction(Direction::Vertical)
                    .constraints([Constraint::Length(3), Constraint::Min(0)])
                    .split(size);
                let titles = ["Heatmap", "Stats", "Timeline", "Commits"];
                let tab_items: Vec<String> = titles.iter().map(|t| t.to_string()).collect();
                let tabs = Tabs::new(tab_items)
                    .block(Block::default().borders(Borders::ALL).title("View Mode"))
                    .highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
                    .select(state.tab_index);
                f.render_widget(tabs, chunks[0]);

                match state.view_mode {
                    ViewMode::Heatmap => draw_heatmap_view(f, chunks[1], &weeks, &state),
                    ViewMode::Statistics => draw_statistics_view(f, chunks[1], &weeks, &state),
                    ViewMode::Timeline => draw_timeline_view(f, chunks[1], &weeks, &state),
                    ViewMode::CommitDetails => draw_commit_details_view(f, chunks[1], &weeks, &mut state),
                }

                draw_file_modal(f, size, &weeks[state.selected]);
                return;
            }

            let chunks = Layout::default()
                .direction(Direction::Vertical)
                .constraints([Constraint::Length(3), Constraint::Min(0)])
                .split(size);

            let titles = ["Heatmap", "Stats", "Timeline", "Commits"];
            let tab_items: Vec<String> = titles.iter().map(|t| t.to_string()).collect();
            let tabs = Tabs::new(tab_items)
                .block(Block::default().borders(Borders::ALL).title("View Mode"))
                .highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
                .select(state.tab_index);
            f.render_widget(tabs, chunks[0]);

            state.view_mode = match state.tab_index {
                0 => ViewMode::Heatmap,
                1 => ViewMode::Statistics,
                2 => ViewMode::Timeline,
                3 => ViewMode::CommitDetails,
                _ => ViewMode::Heatmap,
            };

            match state.view_mode {
                ViewMode::Heatmap => draw_heatmap_view(f, chunks[1], &weeks, &state),
                ViewMode::Statistics => draw_statistics_view(f, chunks[1], &weeks, &state),
                ViewMode::Timeline => draw_timeline_view(f, chunks[1], &weeks, &state),
                ViewMode::CommitDetails => draw_commit_details_view(f, chunks[1], &weeks, &mut state),
            }
        }) {
            eprintln!("TUI draw error: {}", e);
        }

        if poll(Duration::from_millis(200))? {
            match read()? {
                Event::Mouse(mouse_event) => {
                    handle_mouse_event(mouse_event, &mut state, &weeks, &stats, &cache, path.as_deref())?;
                }
                Event::Key(key_event) => {
                    if key_event.kind != KeyEventKind::Press {
                        continue;
                    }

                    if state.show_file_modal {
                        if let KeyCode::Esc = key_event.code {
                            state.show_file_modal = false;
                        }
                        continue;
                    }

                    if state.search_mode {
                        match key_event.code {
                            KeyCode::Esc => {
                                state.search_mode = false;
                                state.search_query.clear();
                                state.filtered_indices = (0..weeks.len()).collect();
                            }
                            KeyCode::Enter => {
                                state.search_mode = false;
                                apply_search_filter(&weeks, &mut state);
                            }
                            KeyCode::Backspace => {
                                state.search_query.pop();
                                apply_search_filter(&weeks, &mut state);
                            }
                            KeyCode::Char(c) => {
                                state.search_query.push(c);
                                apply_search_filter(&weeks, &mut state);
                            }
                            _ => {}
                        }
                    } else {
                        match key_event.code {
                            KeyCode::Char('q') => break,
                            KeyCode::Char('h') | KeyCode::F(1) => state.show_help = !state.show_help,
                            KeyCode::Char('/') => {
                                state.search_mode = true;
                                state.search_query.clear();
                            }
                            KeyCode::Char('f') => {
                                if !weeks.is_empty() && state.selected < weeks.len() {
                                    state.show_file_modal = !state.show_file_modal;
                                }
                            }
                            KeyCode::Enter => {
                                if state.view_mode != ViewMode::CommitDetails
                                    && !weeks.is_empty()
                                    && state.selected < weeks.len()
                                {
                                    load_commit_details(&mut state, &weeks, &stats, &cache, path.as_deref())?;
                                    state.view_mode = ViewMode::CommitDetails;
                                    state.tab_index = 3;
                                }
                            }
                            KeyCode::Char('c') => {
                                if state.view_mode == ViewMode::CommitDetails && !state.commit_details.is_empty() {
                                    if let Some(commit) = state.commit_details.get(state.commit_selected) {
                                        match copy_to_clipboard(&commit.hash) {
                                            Ok(_) => {
                                                state.status_message = Some((
                                                    format!("Copied: {}", commit.short_hash),
                                                    std::time::Instant::now(),
                                                ));
                                            }
                                            Err(err) => {
                                                state.status_message = Some((
                                                    format!("Clipboard error: {}", err),
                                                    std::time::Instant::now(),
                                                ));
                                            }
                                        }
                                    }
                                }
                            }
                            KeyCode::Tab => {
                                state.tab_index = (state.tab_index + 1) % 4;
                            }
                            KeyCode::BackTab => {
                                state.tab_index = if state.tab_index == 0 { 3 } else { state.tab_index - 1 };
                            }
                            KeyCode::Left | KeyCode::Char('j') => {
                                if state.view_mode == ViewMode::CommitDetails {
                                    state.commit_selected = state.commit_selected.saturating_sub(1);
                                } else if state.selected > 0 {
                                    state.selected -= 1;
                                    ensure_selection_in_filtered(&mut state);
                                }
                            }
                            KeyCode::Right | KeyCode::Char('k') => {
                                if state.view_mode == ViewMode::CommitDetails {
                                    if state.commit_selected + 1 < state.commit_details.len() {
                                        state.commit_selected += 1;
                                    }
                                } else if state.selected + 1 < weeks.len() {
                                    state.selected += 1;
                                    ensure_selection_in_filtered(&mut state);
                                }
                            }
                            KeyCode::Home => {
                                if state.view_mode == ViewMode::CommitDetails {
                                    state.commit_selected = 0;
                                } else {
                                    state.selected = 0;
                                    ensure_selection_in_filtered(&mut state);
                                }
                            }
                            KeyCode::End => {
                                if state.view_mode == ViewMode::CommitDetails {
                                    state.commit_selected = state.commit_details.len().saturating_sub(1);
                                } else {
                                    state.selected = weeks.len().saturating_sub(1);
                                    ensure_selection_in_filtered(&mut state);
                                }
                            }
                            KeyCode::PageUp => {
                                if state.view_mode == ViewMode::CommitDetails {
                                    state.commit_selected = state.commit_selected.saturating_sub(10);
                                } else {
                                    state.selected = state.selected.saturating_sub(10);
                                    ensure_selection_in_filtered(&mut state);
                                }
                            }
                            KeyCode::PageDown => {
                                if state.view_mode == ViewMode::CommitDetails {
                                    state.commit_selected = std::cmp::min(
                                        state.commit_selected + 10,
                                        state.commit_details.len().saturating_sub(1),
                                    );
                                } else {
                                    state.selected = std::cmp::min(state.selected + 10, weeks.len().saturating_sub(1));
                                    ensure_selection_in_filtered(&mut state);
                                }
                            }
                            _ => {}
                        }
                    }
                }
                _ => {}
            }
        }
    }

    crossterm::execute!(io::stdout(), DisableMouseCapture)?;
    terminal.clear()?;
    disable_raw_mode()?;
    Ok(())
}

fn handle_mouse_event(
    mouse_event: MouseEvent,
    state: &mut TuiState,
    weeks: &[super::state::WeekStats],
    stats: &[crate::model::CommitStats],
    cache: &Cache,
    path_prefix: Option<&str>,
) -> io::Result<()> {
    match mouse_event.kind {
        MouseEventKind::ScrollUp => {
            if state.view_mode == ViewMode::CommitDetails {
                state.commit_selected = state.commit_selected.saturating_sub(1);
            } else if state.selected > 0 {
                state.selected -= 1;
                ensure_selection_in_filtered(state);
            }
        }
        MouseEventKind::ScrollDown => {
            if state.view_mode == ViewMode::CommitDetails {
                if state.commit_selected + 1 < state.commit_details.len() {
                    state.commit_selected += 1;
                }
            } else if state.selected + 1 < weeks.len() {
                state.selected += 1;
                ensure_selection_in_filtered(state);
            }
        }
        MouseEventKind::Down(MouseButton::Left) => {
            if state.view_mode != ViewMode::CommitDetails && !weeks.is_empty() && state.selected < weeks.len() {
                if let Err(e) = load_commit_details(state, weeks, stats, cache, path_prefix) {
                    eprintln!("Error loading commit details: {}", e);
                } else {
                    state.view_mode = ViewMode::CommitDetails;
                    state.tab_index = 3;
                }
            }
        }
        _ => {}
    }
    Ok(())
}