cargo-port 0.1.4

A TUI for inspecting and managing Rust projects
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::widgets::List;
use ratatui::widgets::ListItem;
use ratatui::widgets::ListState;
use ratatui::widgets::Paragraph;
use tui_pane::PaneTitleCount;
use tui_pane::PaneTitleGroup;
use tui_pane::label_color;
use tui_pane::render_overflow_affordance;

use super::ProjectListPane;
use crate::project;
use crate::scan;
use crate::tui::columns;
use crate::tui::dismiss_target::DismissTarget;
use crate::tui::panes::constants::DISMISS_SUFFIX;
use crate::tui::panes::constants::TITLE_ELLIPSIS;
use crate::tui::render;
use crate::tui::render_context::PaneRenderCtx;
use crate::tui::theme_roles;

pub(super) fn render_project_list_pane_body(
    frame: &mut Frame,
    area: Rect,
    pane: &mut ProjectListPane,
    ctx: &PaneRenderCtx<'_>,
) {
    let projects = ctx.project_list;
    let (mut items, header, summary_line, row_width) = {
        let widths = &projects.cached_fit_widths;
        let items: Vec<ListItem> = super::render_tree_items(ctx, pane, &pane.viewport, widths);
        let total_str = render::format_bytes(
            projects
                .iter()
                .filter_map(|entry| entry.root_item.disk_usage_bytes())
                .sum(),
        );
        let header = columns::header_line(widths, "  Projects");
        let summary = columns::build_summary_cells(widths, &total_str);
        let summary_line = Some(columns::row_to_line(&summary, widths));
        let row_width = u16::try_from(widths.total_width()).unwrap_or(u16::MAX);
        (items, header, summary_line, row_width)
    };

    let total_project_rows = items.len();

    let title = project_panel_title_with_counts(pane, ctx, area.width.saturating_sub(2).into());
    let block = tui_pane::default_pane_chrome().block(title, pane.focus.is_focused);
    let inner = block.inner(area);
    frame.render_widget(block, area);
    if inner.height == 0 {
        pane.viewport.clear_surface();
        pane.body_rect = Rect::ZERO;
        return;
    }

    let header_area = Rect::new(inner.x, inner.y, inner.width, 1);
    frame.render_widget(
        Paragraph::new(header).style(Style::default().fg(theme_roles::column_header_color())),
        header_area,
    );

    let content_area = if inner.height > 1 {
        Rect::new(inner.x, inner.y + 1, inner.width, inner.height - 1)
    } else {
        Rect::new(inner.x, inner.y, inner.width, 0)
    };
    if content_area.height == 0 {
        pane.viewport.clear_surface();
        pane.body_rect = Rect::ZERO;
        return;
    }

    let pin_summary = should_pin_project_summary(
        total_project_rows,
        summary_line.is_some(),
        content_area.height,
    );

    if !pin_summary && let Some(ref line) = summary_line {
        items.push(ListItem::new(line.clone()));
    }

    let list_area = if pin_summary && content_area.height > 1 {
        Rect::new(
            content_area.x,
            content_area.y,
            content_area.width,
            content_area.height - 1,
        )
    } else {
        content_area
    };
    pane.viewport.set_len(total_project_rows);
    pane.viewport.set_content_area(list_area);
    pane.viewport
        .set_viewport_rows(usize::from(list_area.height));
    let project_list = List::new(items);
    let mut list_state = ListState::default().with_selected(Some(projects.cursor()));
    *list_state.offset_mut() = pane.viewport.scroll_offset();
    frame.render_stateful_widget(project_list, list_area, &mut list_state);
    pane.body_rect = list_area;
    pane.viewport.set_scroll_offset(list_state.offset());
    pane.viewport.set_pos(projects.cursor());
    // The pre-Phase-5 implementation also called
    // `ctx.project_list.set_cursor(list_state.selected().unwrap_or(0))`
    // here; that mutation was a no-op (the cursor we passed in via
    // `with_selected` is the same value `selected()` returns) and
    // it required `&mut ProjectList`, which the trait dispatch
    // path doesn't supply. Dropped.
    set_project_list_dismiss_actions(pane, ctx, list_area, row_width);

    if pin_summary && let Some(line) = summary_line {
        render_project_list_footer(frame, content_area, line);
    }

    render_overflow_affordance(
        frame,
        area,
        pane.viewport.overflow(),
        Style::default().fg(label_color()),
    );
}

fn set_project_list_dismiss_actions(
    pane: &mut ProjectListPane,
    ctx: &PaneRenderCtx<'_>,
    list_area: Rect,
    row_width: u16,
) {
    let visible_height = usize::from(list_area.height);
    let visible_start = pane.viewport.scroll_offset();
    let visible_end = pane
        .viewport
        .len()
        .min(visible_start.saturating_add(visible_height));
    let suffix_width = u16::try_from(columns::display_width(DISMISS_SUFFIX)).unwrap_or(u16::MAX);

    let mut actions: Vec<(Rect, DismissTarget)> = Vec::new();
    for (screen_row, row_index) in (visible_start..visible_end).enumerate() {
        let dismiss_target = ctx
            .project_list
            .visible_rows()
            .get(row_index)
            .copied()
            .and_then(|row| ctx.project_list.dismiss_target_for_row_inner(row));
        let Some(target) = dismiss_target else {
            continue;
        };
        let y = list_area
            .y
            .saturating_add(u16::try_from(screen_row).unwrap_or(u16::MAX));
        let x = list_area
            .x
            .saturating_add(row_width.saturating_sub(suffix_width));
        actions.push((Rect::new(x, y, suffix_width, 1), target));
    }
    pane.set_dismiss_actions(actions);
}

fn render_project_list_footer(frame: &mut Frame, content_area: Rect, line: Line<'static>) {
    let footer_area = Rect::new(
        content_area.x,
        content_area.y + content_area.height.saturating_sub(1),
        content_area.width,
        1,
    );
    frame.render_widget(Paragraph::new(line), footer_area);
}

fn project_panel_title_with_counts(
    pane: &ProjectListPane,
    ctx: &PaneRenderCtx<'_>,
    max_width: usize,
) -> String {
    let focused = pane.focus.is_focused;
    let cursor = ctx.project_list.cursor();
    let roots = scan::resolve_include_dirs(&ctx.config.current().tui.include_dirs);

    // No directories configured (first run): point the user at Settings,
    // which auto-opens to the Include dirs field at startup.
    if roots.is_empty() {
        return project_roots_title("Configure Include dirs in Settings", max_width);
    }

    // Count visible rows per root directory and determine which root the
    // cursor is in.
    let mut root_counts: Vec<(String, usize, usize)> = Vec::new(); // (name, count, start_row)
    for root_path in &roots {
        let name = project::home_relative_path(root_path.as_path());
        let count = ctx
            .project_list
            .iter()
            .filter(|item| item.path().starts_with(root_path.as_path()))
            .count();
        let start_row = root_counts
            .last()
            .map_or(0, |(_, prev_count, prev_start)| prev_start + prev_count);
        root_counts.push((name, count, start_row));
    }

    let groups = root_counts
        .iter()
        .map(|(name, count, start)| PaneTitleGroup {
            label:  name.clone().into(),
            len:    *count,
            cursor: focused
                .then_some(cursor)
                .filter(|cursor| *cursor >= *start && *cursor < *start + *count)
                .map(|cursor| cursor - *start),
        })
        .collect();

    let body = PaneTitleCount::Grouped(groups).body();
    project_roots_title(&body, max_width)
}

/// Build the project-list pane title from `body` (the directory list with
/// counts, or the first-run placeholder), padded with one space each side
/// and truncated with an ellipsis when it overflows `max_width`. No label
/// prefix — the home-relative paths read as directories on their own.
pub(super) fn project_roots_title(body: &str, max_width: usize) -> String {
    let full = format!(" {body} ");
    if full.len() <= max_width + 2 {
        return full;
    }

    format!(
        " {} ",
        render::truncate_with_ellipsis(body, max_width.saturating_sub(2), TITLE_ELLIPSIS)
    )
}

pub(super) fn should_pin_project_summary(
    project_rows: usize,
    has_summary: bool,
    inner_height: u16,
) -> bool {
    has_summary && project_rows.saturating_add(1) > usize::from(inner_height)
}

#[cfg(test)]
mod tests {
    use super::project_roots_title;
    use super::should_pin_project_summary;

    #[test]
    fn project_roots_title_adds_ellipsis_when_roots_overflow() {
        let title = project_roots_title("~/rust (12)  ~/work (7)", 20);

        assert_eq!(title, " ~/rust (12)  ~/wo… ");
    }

    #[test]
    fn project_roots_title_keeps_full_body_when_roots_fit() {
        let title = project_roots_title("~/rust (12)", 24);

        assert_eq!(title, " ~/rust (12) ");
    }

    #[test]
    fn project_roots_title_shows_configure_hint_when_no_roots() {
        let title = project_roots_title("Configure Include dirs in Settings", 50);

        assert_eq!(title, " Configure Include dirs in Settings ");
    }

    #[test]
    fn project_summary_stays_inline_when_everything_fits() {
        assert!(!should_pin_project_summary(5, true, 6));
    }

    #[test]
    fn project_summary_pins_when_list_overflows() {
        assert!(should_pin_project_summary(6, true, 6));
    }

    #[test]
    fn project_summary_does_not_pin_without_summary_content() {
        assert!(!should_pin_project_summary(100, false, 6));
    }
}