gitkraft-tui 0.9.1

GitKraft — Git IDE terminal application (Ratatui TUI)
Documentation
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem};
use ratatui::Frame;

use crate::app::{ActivePane, App};

fn graph_color(color_index: usize, graph_colors: &[Color; 8]) -> Color {
    graph_colors[color_index % graph_colors.len()]
}

/// Build the graph column spans for a single row.
///
/// Each column occupies 2 characters.  The node column gets `● `, active
/// pass-through lanes get `│ `, and edges that cross lanes get a simple
/// horizontal/diagonal representation.
fn build_graph_spans(
    row: &gitkraft_core::GraphRow,
    graph_colors: &[Color; 8],
) -> Vec<Span<'static>> {
    // We build a 2-char cell for every column in [0..width).
    // First, figure out what goes in each column.

    let width = row.width;
    if width == 0 {
        return vec![Span::styled(
            "",
            Style::default().fg(graph_color(row.node_color, graph_colors)),
        )];
    }

    // For each column, decide: is it the node? Is there a straight-through
    // edge (from_column == to_column == col)?  Is there a crossing edge that
    // starts or ends here?
    //
    // We keep it simple: first pass fills each cell with the "default" content,
    // then we handle the node column specially.

    // Collect pass-through edges (from == to) per column, and crossing edges.
    let mut column_passthrough: Vec<Option<usize>> = vec![None; width]; // color_index
    let mut has_left_cross = false; // edge going from node to a column left of node
    let mut has_right_cross = false; // edge going from node to a column right of node
    let mut left_cross_color: usize = 0;
    let mut right_cross_color: usize = 0;
    // Track the range of crossing for horizontal lines
    let mut cross_left_col: usize = row.node_column;
    let mut cross_right_col: usize = row.node_column;

    for edge in &row.edges {
        if edge.from_column == edge.to_column {
            // Straight-through or first-parent-continuation
            column_passthrough[edge.to_column] = Some(edge.color_index);
        } else {
            // Crossing edge — from node_column to some other column
            let target = edge.to_column;
            if target < row.node_column {
                has_left_cross = true;
                left_cross_color = edge.color_index;
                if target < cross_left_col {
                    cross_left_col = target;
                }
            } else if target > row.node_column {
                has_right_cross = true;
                right_cross_color = edge.color_index;
                if target > cross_right_col {
                    cross_right_col = target;
                }
            }
        }
    }

    let mut spans: Vec<Span<'static>> = Vec::with_capacity(width + 1);

    for col in 0..width {
        if col == row.node_column {
            // The commit node
            spans.push(Span::styled(
                "".to_string(),
                Style::default()
                    .fg(graph_color(row.node_color, graph_colors))
                    .add_modifier(Modifier::BOLD),
            ));
        } else if let Some(ci) = column_passthrough.get(col).copied().flatten() {
            // There is a straight-through lane here.
            // Check if a horizontal crossing line also passes through this column.
            let in_left_range = has_left_cross && col >= cross_left_col && col < row.node_column;
            let in_right_range = has_right_cross && col > row.node_column && col <= cross_right_col;

            if in_left_range || in_right_range {
                // Vertical lane intersects a horizontal crossing — draw a crossing
                let cross_ci = if in_left_range {
                    left_cross_color
                } else {
                    right_cross_color
                };
                // Use the crossing edge's color for the combined glyph
                spans.push(Span::styled(
                    "├─".to_string(),
                    Style::default().fg(graph_color(cross_ci, graph_colors)),
                ));
            } else {
                spans.push(Span::styled(
                    "".to_string(),
                    Style::default().fg(graph_color(ci, graph_colors)),
                ));
            }
        } else {
            // Empty column — but might have a horizontal crossing line passing through.
            let in_left_range = has_left_cross && col >= cross_left_col && col < row.node_column;
            let in_right_range = has_right_cross && col > row.node_column && col <= cross_right_col;

            if in_left_range {
                if col == cross_left_col {
                    // The target column of the left-crossing edge
                    spans.push(Span::styled(
                        "╭─".to_string(),
                        Style::default().fg(graph_color(left_cross_color, graph_colors)),
                    ));
                } else {
                    spans.push(Span::styled(
                        "──".to_string(),
                        Style::default().fg(graph_color(left_cross_color, graph_colors)),
                    ));
                }
            } else if in_right_range {
                if col == cross_right_col {
                    // The target column of the right-crossing edge
                    spans.push(Span::styled(
                        "─╮".to_string(),
                        Style::default().fg(graph_color(right_cross_color, graph_colors)),
                    ));
                } else {
                    spans.push(Span::styled(
                        "──".to_string(),
                        Style::default().fg(graph_color(right_cross_color, graph_colors)),
                    ));
                }
            } else {
                spans.push(Span::styled(
                    "  ".to_string(),
                    Style::default().fg(Color::DarkGray),
                ));
            }
        }
    }

    // Add a separator after the graph portion
    spans.push(Span::styled(" ", Style::default()));
    spans
}

/// Render the commit log list inside the given `area`.
pub fn render(app: &mut App, frame: &mut Frame, area: Rect) {
    let theme = app.theme();
    let is_active = app.active_pane == ActivePane::CommitLog;

    let border_color = if is_active {
        theme.border_active
    } else {
        theme.border_inactive
    };

    let selected_count = app.tab().selected_commits.len();
    let title = if app.tab().search_active {
        format!(
            " Commit Log — Search: \"{}\" ({}) ",
            app.tab().search_query,
            app.tab().search_results.len()
        )
    } else if selected_count > 1 {
        format!(
            " Commit Log ({}) — {} selected [J/K shrink] ",
            app.tab().commits.len(),
            selected_count
        )
    } else if is_active {
        format!(" Commit Log ({}) [J/K select] ", app.tab().commits.len())
    } else {
        format!(" Commit Log ({}) ", app.tab().commits.len())
    };

    let block = Block::default()
        .title(title)
        .borders(Borders::ALL)
        .border_style(Style::default().fg(border_color))
        .style(Style::default().bg(theme.bg));

    // Use search results if search is active, otherwise show the full commit list
    let commits_to_show: &Vec<gitkraft_core::CommitInfo> =
        if app.tab().search_active && !app.tab().search_results.is_empty() {
            &app.tab().search_results
        } else {
            &app.tab().commits
        };

    if commits_to_show.is_empty() {
        let items: Vec<ListItem> = vec![ListItem::new(Line::from(vec![Span::styled(
            "  No commits yet",
            Style::default().fg(theme.text_muted),
        )]))];

        let list = List::new(items).block(block);
        frame.render_widget(list, area);
        return;
    }

    let show_graph = !app.tab().search_active || app.tab().search_results.is_empty();

    let items: Vec<ListItem> = commits_to_show
        .iter()
        .enumerate()
        .map(|(idx, commit)| {
            let summary = gitkraft_core::truncate_str(&commit.summary, 50);

            let relative = commit.relative_time();

            let is_primary_selected = app.tab().commit_list_state.selected() == Some(idx);

            // Selection badge: position in selected_commits range (1-based), or spaces
            let badge_span =
                if let Some(pos) = app.tab().selected_commits.iter().position(|&i| i == idx) {
                    Span::styled(format!("{:>2}", pos + 1), Style::default().fg(theme.accent))
                } else {
                    Span::raw("  ")
                };

            // Build graph prefix spans for this row (skip graph for search results)
            let mut spans = vec![badge_span, Span::raw(" ")];
            if show_graph {
                if let Some(row) = app.tab().graph_rows.get(idx) {
                    spans.extend(build_graph_spans(row, &theme.graph_colors));
                } else {
                    spans.push(Span::raw("  "));
                }
            } else {
                spans.push(Span::raw("  "));
            }

            // Append the commit info spans
            spans.push(Span::styled(
                format!("{} ", commit.short_oid),
                Style::default().fg(theme.warning),
            ));
            spans.push(Span::styled(
                summary,
                Style::default().fg(theme.text_primary),
            ));
            spans.push(Span::styled(
                format!(" ({}", commit.author_name),
                Style::default().fg(theme.accent),
            ));
            spans.push(Span::styled(
                format!(", {})", relative),
                Style::default().fg(theme.text_muted),
            ));

            let style = if !is_primary_selected && app.tab().selected_commits.contains(&idx) {
                Style::default().bg(theme.sel_bg)
            } else {
                Style::default()
            };

            ListItem::new(Line::from(spans)).style(style)
        })
        .collect();

    let list = List::new(items)
        .block(block)
        .highlight_style(
            Style::default()
                .bg(theme.sel_bg)
                .fg(theme.text_primary)
                .add_modifier(Modifier::BOLD),
        )
        .highlight_symbol("");

    frame.render_stateful_widget(list, area, &mut app.tab_mut().commit_list_state);

    // Render commit-action popup if open
    if !app.tab().commit_action_items.is_empty() {
        render_action_popup(app, frame, area);
    }
}

/// Render the commit-action popup centred over `area`.
fn render_action_popup(app: &mut App, frame: &mut Frame, area: Rect) {
    let theme = app.theme();

    // Collect flat items and separator positions from COMMIT_MENU_GROUPS
    let mut items: Vec<ListItem> = Vec::new();
    let cursor = app.tab().commit_action_cursor;
    let mut flat_idx: usize = 0;

    for (group_idx, group) in gitkraft_core::COMMIT_MENU_GROUPS.iter().enumerate() {
        if group_idx > 0 {
            // Separator line
            items.push(ListItem::new(Line::from(Span::styled(
                "─────────────────────────────",
                Style::default().fg(theme.border_inactive),
            ))));
        }
        for &kind in *group {
            let is_selected = flat_idx == cursor;
            let style = if is_selected {
                Style::default()
                    .fg(theme.text_primary)
                    .bg(theme.sel_bg)
                    .add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(theme.text_primary)
            };
            let prefix = if is_selected { "" } else { "  " };
            items.push(ListItem::new(Line::from(vec![
                Span::styled(prefix, style),
                Span::styled(kind.label(), style),
            ])));
            flat_idx += 1;
        }
    }

    // Size: width = longest label + 6, height = items + 2 border
    let popup_width = (gitkraft_core::COMMIT_MENU_GROUPS
        .iter()
        .flat_map(|g| g.iter())
        .map(|k| k.label().len())
        .max()
        .unwrap_or(30)
        + 6) as u16;
    let popup_height = (items.len() + 2) as u16;

    // Centre within `area`
    let x = area.x + area.width.saturating_sub(popup_width) / 2;
    let y = area.y + area.height.saturating_sub(popup_height) / 2;
    let popup_rect = Rect {
        x,
        y,
        width: popup_width.min(area.width),
        height: popup_height.min(area.height),
    };

    let oid_short = app
        .tab()
        .pending_commit_action_oid
        .as_deref()
        .map(|o| &o[..o.len().min(7)])
        .unwrap_or("?");

    let block = Block::default()
        .title(format!(" Actions: {oid_short} "))
        .borders(Borders::ALL)
        .border_style(Style::default().fg(theme.border_active))
        .style(Style::default().bg(theme.bg));

    frame.render_widget(Clear, popup_rect);
    let list = List::new(items).block(block);
    frame.render_widget(list, popup_rect);
}