use crate::cli::render::{self, Align, GlyphMode, Icons, LoreRenderer, StyledCell, Table, Theme}; use super::types::{ ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary, SinceLastCheck, }; // ─── Layout Helpers ───────────────────────────────────────────────────────── /// Compute the title/summary column width for a section given its fixed overhead. /// Returns a width clamped to [20, 80]. fn title_width(overhead: usize) -> usize { render::terminal_width() .saturating_sub(overhead) .clamp(20, 80) } // ─── Glyph Mode Helper ────────────────────────────────────────────────────── /// Get the current glyph mode, defaulting to Unicode if renderer not initialized. fn glyph_mode() -> GlyphMode { LoreRenderer::try_get().map_or(GlyphMode::Unicode, LoreRenderer::glyph_mode) } // ─── Attention Icons ───────────────────────────────────────────────────────── /// Return the attention icon for the current glyph mode. fn attention_icon(state: &AttentionState) -> &'static str { let mode = glyph_mode(); match state { AttentionState::NeedsAttention => match mode { GlyphMode::Nerd => "\u{f0f3}", // bell GlyphMode::Unicode => "\u{25c6}", // diamond GlyphMode::Ascii => "[!]", }, AttentionState::NotStarted => match mode { GlyphMode::Nerd => "\u{f005}", // star GlyphMode::Unicode => "\u{2605}", // black star GlyphMode::Ascii => "[*]", }, AttentionState::AwaitingResponse => match mode { GlyphMode::Nerd => "\u{f017}", // clock GlyphMode::Unicode => "\u{25f7}", // white circle with upper right quadrant GlyphMode::Ascii => "[~]", }, AttentionState::Stale => match mode { GlyphMode::Nerd => "\u{f54c}", // skull GlyphMode::Unicode => "\u{2620}", // skull and crossbones GlyphMode::Ascii => "[x]", }, AttentionState::NotReady => match mode { GlyphMode::Nerd => "\u{f040}", // pencil GlyphMode::Unicode => "\u{270e}", // lower right pencil GlyphMode::Ascii => "[D]", }, } } /// Style for an attention state. fn attention_style(state: &AttentionState) -> lipgloss::Style { match state { AttentionState::NeedsAttention => Theme::warning(), AttentionState::NotStarted => Theme::info(), AttentionState::AwaitingResponse | AttentionState::Stale => Theme::dim(), AttentionState::NotReady => Theme::state_draft(), } } /// Render the styled attention icon for an item. fn styled_attention(state: &AttentionState) -> String { let icon = attention_icon(state); attention_style(state).render(icon) } // ─── Merge Status Labels ──────────────────────────────────────────────────── /// Convert GitLab's `detailed_merge_status` API values to human-friendly labels. fn humanize_merge_status(status: &str) -> &str { match status { "not_approved" => "needs approval", "requested_changes" => "changes requested", "mergeable" => "ready to merge", "not_open" => "not open", "checking" => "checking", "ci_must_pass" => "CI pending", "ci_still_running" => "CI running", "discussions_not_resolved" => "unresolved threads", "draft_status" => "draft", "need_rebase" => "needs rebase", "conflict" | "has_conflicts" => "has conflicts", "blocked_status" => "blocked", "approvals_syncing" => "syncing approvals", "jira_association_missing" => "missing Jira link", "unchecked" => "unchecked", other => other, } } // ─── Event Badges ──────────────────────────────────────────────────────────── /// Return the badge label text for an activity event type. fn activity_badge_label(event_type: &ActivityEventType) -> String { match event_type { ActivityEventType::Note => "note", ActivityEventType::StatusChange => "status", ActivityEventType::LabelChange => "label", ActivityEventType::Assign | ActivityEventType::Unassign => "assign", ActivityEventType::ReviewRequest => "review", ActivityEventType::MilestoneChange => "milestone", } .to_string() } /// Return the style for an activity event badge. fn activity_badge_style(event_type: &ActivityEventType) -> lipgloss::Style { match event_type { ActivityEventType::Note => Theme::info(), ActivityEventType::StatusChange => Theme::warning(), ActivityEventType::LabelChange => Theme::accent(), ActivityEventType::Assign | ActivityEventType::Unassign | ActivityEventType::ReviewRequest => Theme::success(), ActivityEventType::MilestoneChange => accent_magenta(), } } /// Magenta accent for milestone badges. fn accent_magenta() -> lipgloss::Style { if LoreRenderer::try_get().is_some_and(LoreRenderer::colors_enabled) { lipgloss::Style::new().foreground("#d946ef") } else { lipgloss::Style::new() } } /// Very dark gray for system events (label, assign, status, milestone, review). fn system_event_style() -> lipgloss::Style { if LoreRenderer::try_get().is_some_and(LoreRenderer::colors_enabled) { lipgloss::Style::new().foreground("#555555") } else { lipgloss::Style::new().faint() } } // ─── Summary Header ───────────────────────────────────────────────────────── /// Print the summary header with counts and attention legend (Task #14). pub fn print_summary_header(summary: &MeSummary, username: &str) { println!(); println!( "{}", Theme::bold().render(&format!( "{} {} -- Personal Dashboard", Icons::user(), username, )) ); println!("{}", "\u{2500}".repeat(render::terminal_width())); // Counts line let needs = if summary.needs_attention_count > 0 { Theme::warning().render(&format!("{} need attention", summary.needs_attention_count)) } else { Theme::dim().render("0 need attention") }; println!( " {} projects {} issues {} authored MRs {} reviewing MRs {}", summary.project_count, summary.open_issue_count, summary.authored_mr_count, summary.reviewing_mr_count, needs, ); // Attention legend print_attention_legend(); } /// Print the attention icon legend. fn print_attention_legend() { println!(); let states = [ (AttentionState::NeedsAttention, "needs attention"), (AttentionState::NotStarted, "not started"), (AttentionState::AwaitingResponse, "awaiting response"), (AttentionState::Stale, "stale (30d+)"), (AttentionState::NotReady, "draft (not ready)"), ]; let legend: Vec = states .iter() .map(|(state, label)| format!("{} {}", styled_attention(state), Theme::dim().render(label))) .collect(); println!(" {}", legend.join(" ")); } // ─── Open Issues Section ───────────────────────────────────────────────────── /// Print the open issues section (Task #15). pub fn print_issues_section(issues: &[MeIssue], single_project: bool) { if issues.is_empty() { println!("{}", render::section_divider("Open Issues (0)")); println!( " {}", Theme::dim().render("No open issues assigned to you.") ); return; } println!( "{}", render::section_divider(&format!("Open Issues ({})", issues.len())) ); for issue in issues { let attn = styled_attention(&issue.attention_state); let ref_str = format!("#{}", issue.iid); let status = issue .status_name .as_deref() .map(|s| format!(" [{s}]")) .unwrap_or_default(); let time = render::format_relative_time(issue.updated_at); // Line 1: attention icon, issue ref, title, status, relative time println!( " {} {} {}{} {}", attn, Theme::issue_ref().render(&ref_str), render::truncate(&issue.title, title_width(43)), Theme::dim().render(&status), Theme::dim().render(&time), ); // Line 2: project path (suppressed in single-project mode) if !single_project { println!(" {}", Theme::dim().render(&issue.project_path),); } } } // ─── MR Sections ───────────────────────────────────────────────────────────── /// Print the authored MRs section (Task #16). pub fn print_authored_mrs_section(mrs: &[MeMr], single_project: bool) { if mrs.is_empty() { println!("{}", render::section_divider("Authored MRs (0)")); println!( " {}", Theme::dim().render("No open MRs authored by you.") ); return; } println!( "{}", render::section_divider(&format!("Authored MRs ({})", mrs.len())) ); for mr in mrs { let attn = styled_attention(&mr.attention_state); let ref_str = format!("!{}", mr.iid); let draft = if mr.draft { Theme::state_draft().render(" [draft]") } else { String::new() }; let merge_status = mr .detailed_merge_status .as_deref() .filter(|s| !s.is_empty() && *s != "not_open") .map(|s| format!(" ({})", humanize_merge_status(s))) .unwrap_or_default(); let time = render::format_relative_time(mr.updated_at); // Line 1: attention, MR ref, title, draft, merge status, time println!( " {} {} {}{}{} {}", attn, Theme::mr_ref().render(&ref_str), render::truncate(&mr.title, title_width(48)), draft, Theme::dim().render(&merge_status), Theme::dim().render(&time), ); // Line 2: project path if !single_project { println!(" {}", Theme::dim().render(&mr.project_path),); } } } /// Print the reviewing MRs section (Task #16). pub fn print_reviewing_mrs_section(mrs: &[MeMr], single_project: bool) { if mrs.is_empty() { println!("{}", render::section_divider("Reviewing MRs (0)")); println!( " {}", Theme::dim().render("No open MRs awaiting your review.") ); return; } println!( "{}", render::section_divider(&format!("Reviewing MRs ({})", mrs.len())) ); for mr in mrs { let attn = styled_attention(&mr.attention_state); let ref_str = format!("!{}", mr.iid); let author = mr .author_username .as_deref() .map(|a| format!(" by {}", Theme::username().render(&format!("@{a}")))) .unwrap_or_default(); let draft = if mr.draft { Theme::state_draft().render(" [draft]") } else { String::new() }; let time = render::format_relative_time(mr.updated_at); // Line 1: attention, MR ref, title, author, draft, time println!( " {} {} {}{}{} {}", attn, Theme::mr_ref().render(&ref_str), render::truncate(&mr.title, title_width(50)), author, draft, Theme::dim().render(&time), ); // Line 2: project path if !single_project { println!(" {}", Theme::dim().render(&mr.project_path),); } } } // ─── Activity Feed ─────────────────────────────────────────────────────────── /// Print the activity feed section (Task #17). pub fn print_activity_section(events: &[MeActivityEvent], single_project: bool) { if events.is_empty() { println!("{}", render::section_divider("Activity (0)")); println!( " {}", Theme::dim().render("No recent activity on your items.") ); return; } println!( "{}", render::section_divider(&format!("Activity ({})", events.len())) ); // Columns: badge | ref | summary | actor | time // Table handles alignment, padding, and truncation automatically. let summary_max = title_width(46); let mut table = Table::new() .columns(5) .indent(4) .align(1, Align::Right) .align(4, Align::Right) .max_width(2, summary_max); for event in events { let badge_label = activity_badge_label(&event.event_type); let badge_style = activity_badge_style(&event.event_type); let ref_text = match event.entity_type.as_str() { "issue" => format!("#{}", event.entity_iid), "mr" => format!("!{}", event.entity_iid), _ => format!("{}:{}", event.entity_type, event.entity_iid), }; let is_system = !matches!(event.event_type, ActivityEventType::Note); // System events → very dark gray; own notes → standard dim; else → full color. let subdued = is_system || event.is_own; let subdued_style = || { if is_system { system_event_style() } else { Theme::dim() } }; let badge_style_final = if subdued { subdued_style() } else { badge_style }; let ref_style = if subdued { Some(subdued_style()) } else { match event.entity_type.as_str() { "issue" => Some(Theme::issue_ref()), "mr" => Some(Theme::mr_ref()), _ => None, } }; let clean_summary = event.summary.replace('\n', " "); let summary_style: Option = if subdued { Some(subdued_style()) } else { None }; let actor_text = if event.is_own { event .actor .as_deref() .map_or("(you)".to_string(), |a| format!("@{a} (you)")) } else { event .actor .as_deref() .map_or(String::new(), |a| format!("@{a}")) }; let actor_style = if subdued { subdued_style() } else { Theme::username() }; let time = render::format_relative_time_compact(event.timestamp); table.add_row(vec![ StyledCell::styled(badge_label, badge_style_final), match ref_style { Some(s) => StyledCell::styled(ref_text, s), None => StyledCell::plain(ref_text), }, match summary_style { Some(s) => StyledCell::styled(clean_summary, s), None => StyledCell::plain(clean_summary), }, StyledCell::styled(actor_text, actor_style), StyledCell::styled(time, Theme::dim()), ]); } // Render table rows and interleave per-event detail lines let rendered = table.render(); for (line, event) in rendered.lines().zip(events.iter()) { println!("{line}"); if !single_project { println!(" {}", Theme::dim().render(&event.project_path)); } if let Some(preview) = &event.body_preview && !preview.is_empty() { let truncated = render::truncate(preview, 60); println!(" {}", Theme::dim().render(&format!("\"{truncated}\""))); } } } /// Format an entity reference (#N for issues, !N for MRs), right-aligned to 6 chars. #[cfg(test)] fn format_entity_ref(entity_type: &str, iid: i64) -> String { match entity_type { "issue" => { let s = format!("{:>6}", format!("#{iid}")); Theme::issue_ref().render(&s) } "mr" => { let s = format!("{:>6}", format!("!{iid}")); Theme::mr_ref().render(&s) } _ => format!("{:>6}", format!("{entity_type}:{iid}")), } } // ─── Since Last Check ──────────────────────────────────────────────────────── /// Print the "since last check" section at the top of the dashboard. pub fn print_since_last_check_section(since: &SinceLastCheck, single_project: bool) { let relative = render::format_relative_time(since.cursor_ms); if since.groups.is_empty() { println!( "\n {}", Theme::dim().render(&format!( "No new events since {} ({relative})", render::format_datetime(since.cursor_ms), )) ); return; } println!( "{}", render::section_divider(&format!("Since Last Check ({relative})")) ); for group in &since.groups { // Entity header: !247 Fix race condition... let ref_str = match group.entity_type.as_str() { "issue" => format!("#{}", group.entity_iid), "mr" => format!("!{}", group.entity_iid), _ => format!("{}:{}", group.entity_type, group.entity_iid), }; let ref_style = match group.entity_type.as_str() { "issue" => Theme::issue_ref(), "mr" => Theme::mr_ref(), _ => Theme::bold(), }; println!(); println!( " {} {}", ref_style.render(&ref_str), Theme::bold().render(&render::truncate(&group.entity_title, title_width(20))), ); if !single_project { println!(" {}", Theme::dim().render(&group.project_path)); } // Sub-events as indented rows let summary_max = title_width(42); let mut table = Table::new() .columns(3) .indent(6) .align(2, Align::Right) .max_width(1, summary_max); for event in &group.events { let badge = activity_badge_label(&event.event_type); let badge_style = activity_badge_style(&event.event_type); let actor_prefix = event .actor .as_deref() .map(|a| format!("@{a} ")) .unwrap_or_default(); let clean_summary = event.summary.replace('\n', " "); let summary_text = format!("{actor_prefix}{clean_summary}"); let time = render::format_relative_time_compact(event.timestamp); table.add_row(vec![ StyledCell::styled(badge, badge_style), StyledCell::plain(summary_text), StyledCell::styled(time, Theme::dim()), ]); } let rendered = table.render(); for (line, event) in rendered.lines().zip(group.events.iter()) { println!("{line}"); if let Some(preview) = &event.body_preview && !preview.is_empty() { let truncated = render::truncate(preview, 60); println!( " {}", Theme::dim().render(&format!("\"{truncated}\"")) ); } } } // Footer println!( "\n {}", Theme::dim().render(&format!( "{} events across {} items", since.total_event_count, since.groups.len() )) ); } // ─── Full Dashboard ────────────────────────────────────────────────────────── /// Render the complete human-mode dashboard. pub fn print_me_dashboard(dashboard: &MeDashboard, single_project: bool) { if let Some(ref since) = dashboard.since_last_check { print_since_last_check_section(since, single_project); } print_summary_header(&dashboard.summary, &dashboard.username); print_issues_section(&dashboard.open_issues, single_project); print_authored_mrs_section(&dashboard.open_mrs_authored, single_project); print_reviewing_mrs_section(&dashboard.reviewing_mrs, single_project); print_activity_section(&dashboard.activity, single_project); println!(); } /// Render a filtered dashboard (only requested sections). pub fn print_me_dashboard_filtered( dashboard: &MeDashboard, single_project: bool, show_issues: bool, show_mrs: bool, show_activity: bool, ) { if let Some(ref since) = dashboard.since_last_check { print_since_last_check_section(since, single_project); } print_summary_header(&dashboard.summary, &dashboard.username); if show_issues { print_issues_section(&dashboard.open_issues, single_project); } if show_mrs { print_authored_mrs_section(&dashboard.open_mrs_authored, single_project); print_reviewing_mrs_section(&dashboard.reviewing_mrs, single_project); } if show_activity { print_activity_section(&dashboard.activity, single_project); } println!(); } // ─── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[test] fn attention_icon_returns_nonempty_for_all_states() { let states = [ AttentionState::NeedsAttention, AttentionState::NotStarted, AttentionState::AwaitingResponse, AttentionState::Stale, AttentionState::NotReady, ]; for state in &states { assert!(!attention_icon(state).is_empty(), "empty for {state:?}"); } } #[test] fn format_entity_ref_issue() { let result = format_entity_ref("issue", 42); assert!(result.contains("42"), "got: {result}"); } #[test] fn format_entity_ref_mr() { let result = format_entity_ref("mr", 99); assert!(result.contains("99"), "got: {result}"); } #[test] fn activity_badge_label_returns_nonempty_for_all_types() { let types = [ ActivityEventType::Note, ActivityEventType::StatusChange, ActivityEventType::LabelChange, ActivityEventType::Assign, ActivityEventType::Unassign, ActivityEventType::ReviewRequest, ActivityEventType::MilestoneChange, ]; for t in &types { assert!(!activity_badge_label(t).is_empty(), "empty for {t:?}"); } } }