quinjet 0.0.72

A fast, live, keyboard-first Git source-control interface for the terminal
#[cfg_attr(not(test), expect(clippy::wildcard_imports, reason = "shared"))]
use super::*;

pub(super) fn draw_stack_member_detail(
    frame: &mut Frame<'_>,
    area: Rect,
    app: &mut App,
    theme: &Theme,
    stack_hits: &mut Vec<StackInspectorHitArea>,
    link_hits: &mut Vec<LinkHit>,
) -> (
    Option<Rect>,
    Vec<ContentFileHit>,
    Vec<ContentStepHit>,
    Vec<ContentReviewHit>,
) {
    let focused = app.focus == Focus::Content && app.modal.is_none();
    let block = panel_block(stack_member_panel_title(app), focused, theme);
    let inner = block.inner(area);
    frame.render_widget(block, area);
    if inner.width == 0 || inner.height == 0 {
        return (None, Vec::new(), Vec::new(), Vec::new());
    }
    let header_height = inner.height.min(3);
    let actions_height = u16::from(inner.height >= 5);
    let header = Rect::new(inner.x, inner.y, inner.width, header_height);
    let body = Rect::new(
        inner.x,
        inner.y.saturating_add(header_height),
        inner.width,
        inner
            .height
            .saturating_sub(header_height)
            .saturating_sub(actions_height),
    );
    draw_stack_member_identity(frame, header, app, theme, stack_hits, link_hits);
    if actions_height > 0 {
        draw_review_actions(
            frame,
            Rect::new(
                inner.x,
                inner.bottom().saturating_sub(actions_height),
                inner.width,
                actions_height,
            ),
            app,
            theme,
            stack_hits,
        );
    }
    if body.height == 0 {
        return (None, Vec::new(), Vec::new(), Vec::new());
    }
    if app.stack_inspector.section == StackMemberSection::Files {
        return draw_content(frame, body, app, theme, link_hits);
    }
    let width = usize::from(body.width.saturating_sub(1).max(1));
    let key = (
        app.stack_inspector.section,
        width,
        app.stack_inspector.content_generation,
        app.relative_time_generation,
    );
    if app.stack_inspector_content_rows_key != Some(key) {
        let (rows, links) = pull_request_stack_rows::stack_member_rows(app, width, theme);
        app.stack_inspector_content_width = rows
            .iter()
            .filter(|row| row.wide)
            .map(|row| row.line.width())
            .max()
            .unwrap_or_default();
        app.stack_inspector_content_rows = rows;
        app.stack_inspector_content_links = links;
        app.stack_inspector_content_rows_key = Some(key);
    }
    let rows = &app.stack_inspector_content_rows;
    let max_scroll = rows.len().saturating_sub(usize::from(body.height));
    app.content_scroll = app.content_scroll.min(max_scroll);
    let overflow = rows
        .iter()
        .skip(app.content_scroll)
        .take(usize::from(body.height))
        .filter(|row| row.wide)
        .map(|row| row.line.width().saturating_sub(width))
        .max()
        .unwrap_or_default();
    app.horizontal_scroll_limit = overflow;
    app.horizontal_scroll = app.horizontal_scroll.min(overflow);
    app.content_at_bottom = app.content_scroll == max_scroll;
    for (offset, row) in rows
        .iter()
        .skip(app.content_scroll)
        .take(usize::from(body.height))
        .enumerate()
    {
        let source_row = app.content_scroll.saturating_add(offset);
        let row_area = Rect::new(body.x, body.y.saturating_add(cells(offset)), body.width, 1);
        if row.wide && row.line.width() > width {
            app.horizontal_scroll_regions.push(row_area);
        }
        frame.render_widget(
            Paragraph::new(if row.wide {
                shift_line(&row.line, app.horizontal_scroll, width)
            } else {
                row.line.clone()
            })
            .style(Style::default().bg(theme.panel)),
            row_area,
        );
        for link in app
            .stack_inspector_content_links
            .iter()
            .filter(|link| link.row == source_row)
        {
            Link::new(link.target.clone()).register(
                horizontally_scrolled_link_area(
                    row_area,
                    if row.wide { app.horizontal_scroll } else { 0 },
                    link.start,
                    link.width,
                ),
                link_hits,
            );
        }
    }
    draw_scrollbar(frame, body, app.content_scroll, rows.len(), theme);
    (None, Vec::new(), Vec::new(), Vec::new())
}

fn stack_member_panel_title(app: &App) -> String {
    let position = app.pull_request_stack_cursor.unwrap_or_default();
    let size = app
        .pull_request_stack
        .as_ref()
        .map_or(0, |stack| stack.size);
    let cache = match app.stack_inspector.section {
        StackMemberSection::Summary if app.stack_inspector.selected_from_cache => " · cached",
        StackMemberSection::Conversation if app.stack_inspector.conversation.from_cache => {
            " · cached"
        }
        StackMemberSection::Checks if app.stack_inspector.selected_checks().from_cache => {
            " · cached"
        }
        StackMemberSection::Commits if app.stack_inspector.commits.from_cache => " · cached",
        StackMemberSection::Files
        | StackMemberSection::Summary
        | StackMemberSection::Conversation
        | StackMemberSection::Checks
        | StackMemberSection::Commits => "",
    };
    format!(" Member {position}/{size}{cache} ")
}

fn draw_stack_member_identity(
    frame: &mut Frame<'_>,
    area: Rect,
    app: &App,
    theme: &Theme,
    stack_hits: &mut Vec<StackInspectorHitArea>,
    link_hits: &mut Vec<LinkHit>,
) {
    let pull_request = app
        .stack_inspector
        .selected_pull_request
        .as_ref()
        .or(app.stack_inspector.selected_locator.as_ref());
    if let Some(pull_request) = pull_request {
        let state = if pull_request.is_draft {
            "DRAFT"
        } else {
            pull_request.state.as_str()
        };
        let prefix = format!(" #{} ", pull_request.number);
        let suffix = format!("  {state} ");
        let title = truncate_end(
            &pull_request.title,
            usize::from(area.width)
                .saturating_sub(prefix.width())
                .saturating_sub(suffix.width()),
        );
        frame.render_widget(
            Paragraph::new(Line::from(vec![
                Span::styled(prefix.clone(), Link::style(theme)),
                Span::styled(title.clone(), Link::style(theme)),
                Span::styled(
                    suffix,
                    Style::default()
                        .fg(stack_member_state_color(state, theme))
                        .add_modifier(Modifier::BOLD),
                ),
            ]))
            .style(Style::default().bg(theme.panel_alt)),
            Rect::new(area.x, area.y, area.width, 1),
        );
        if !pull_request.url.is_empty() {
            Link::new(OpenTarget::Browser(pull_request.url.clone())).register(
                clipped_link_area(
                    area.x,
                    area.y,
                    prefix.width().saturating_add(title.width()),
                    area,
                ),
                link_hits,
            );
        }
        if area.height >= 2 {
            let metadata = format!(
                " @{} · {} -> {} · updated {}",
                pull_request.author,
                pull_request.base_ref,
                pull_request.head_ref,
                format_relative_timestamp(&pull_request.updated_at),
            );
            frame.render_widget(
                Paragraph::new(truncate_end(&metadata, usize::from(area.width)))
                    .style(Style::default().fg(theme.muted).bg(theme.panel_alt)),
                Rect::new(area.x, area.y.saturating_add(1), area.width, 1),
            );
        }
    } else {
        frame.render_widget(
            Paragraph::new(" Loading stack member metadata…")
                .style(Style::default().fg(theme.muted).bg(theme.panel_alt)),
            Rect::new(area.x, area.y, area.width, area.height.min(2)),
        );
    }
    if area.height >= 3 {
        draw_stack_member_section_tabs(
            frame,
            Rect::new(area.x, area.y.saturating_add(2), area.width, 1),
            app,
            theme,
            stack_hits,
        );
    }
}

fn draw_stack_member_section_tabs(
    frame: &mut Frame<'_>,
    area: Rect,
    app: &App,
    theme: &Theme,
    hits: &mut Vec<StackInspectorHitArea>,
) {
    let sections = [
        StackMemberSection::Files,
        StackMemberSection::Summary,
        StackMemberSection::Conversation,
        StackMemberSection::Checks,
        StackMemberSection::Commits,
    ];
    let areas = Layout::horizontal([
        Constraint::Ratio(1, 5),
        Constraint::Ratio(1, 5),
        Constraint::Ratio(1, 5),
        Constraint::Ratio(1, 5),
        Constraint::Ratio(1, 5),
    ])
    .split(area);
    for (index, section) in sections.into_iter().enumerate() {
        let Some(tab) = areas.get(index).copied() else {
            continue;
        };
        let active = section == app.stack_inspector.section;
        frame.render_widget(
            Paragraph::new(stack_member_section_label(section, app))
                .alignment(Alignment::Center)
                .style(
                    Style::default()
                        .fg(if active { theme.text } else { theme.muted })
                        .bg(if active {
                            theme.accent_soft
                        } else {
                            theme.panel
                        })
                        .add_modifier(if active {
                            Modifier::BOLD
                        } else {
                            Modifier::empty()
                        }),
                ),
            tab,
        );
        hits.push(StackInspectorHitArea {
            area: tab,
            target: StackInspectorHit::Section(section),
        });
    }
}

fn stack_member_section_label(section: StackMemberSection, app: &App) -> String {
    match section {
        StackMemberSection::Files => format!(
            "1 Files {}",
            app.stack_inspector
                .selected_pull_request
                .as_ref()
                .or(app.stack_inspector.selected_locator.as_ref())
                .map_or(0, |pull_request| pull_request.changed_files)
        ),
        StackMemberSection::Summary => "2 Summary".to_owned(),
        StackMemberSection::Conversation => format!(
            "3 Conversation {}",
            app.stack_inspector.conversation.comment_count()
        ),
        StackMemberSection::Checks => {
            format!(
                "4 Checks {}",
                app.stack_inspector.selected_checks().checks.len()
            )
        }
        StackMemberSection::Commits => {
            format!("5 Commits {}", app.stack_inspector.commits.total_commits)
        }
    }
}

fn draw_review_actions(
    frame: &mut Frame<'_>,
    area: Rect,
    app: &App,
    theme: &Theme,
    hits: &mut Vec<StackInspectorHitArea>,
) {
    let [previous, review, next] = Layout::horizontal([
        Constraint::Ratio(1, 4),
        Constraint::Ratio(2, 4),
        Constraint::Ratio(1, 4),
    ])
    .areas(area);
    let position = app.pull_request_stack_cursor.unwrap_or_default();
    let size = app
        .pull_request_stack
        .as_ref()
        .map_or(0, |stack| stack.size);
    let review_enabled = app
        .selected_pull_request()
        .is_some_and(|pull_request| pull_request.state == "OPEN");
    let actions = [
        (
            previous,
            " [p Previous] ",
            StackInspectorHit::Previous,
            position > 1,
        ),
        (
            review,
            " [r Submit review] ",
            StackInspectorHit::Review,
            review_enabled,
        ),
        (next, " [n Next] ", StackInspectorHit::Next, position < size),
    ];
    for (target, label, hit, enabled) in actions {
        frame.render_widget(
            Paragraph::new(label).alignment(Alignment::Center).style(
                Style::default()
                    .fg(if enabled { theme.accent } else { theme.muted })
                    .bg(if hit == StackInspectorHit::Review {
                        theme.accent_soft
                    } else {
                        theme.panel_alt
                    })
                    .add_modifier(if enabled {
                        Modifier::BOLD
                    } else {
                        Modifier::empty()
                    }),
            ),
            target,
        );
        if enabled {
            hits.push(StackInspectorHitArea {
                area: target,
                target: hit,
            });
        }
    }
}

const fn stack_member_state_color(state: &str, theme: &Theme) -> Color {
    match state.as_bytes() {
        b"OPEN" => theme.success,
        b"MERGED" => theme.accent,
        b"CLOSED" => theme.removed,
        _ => theme.modified,
    }
}