#![allow(dead_code)] // Phase 2: consumed by view/mod.rs screen dispatch //! Issue detail screen view. //! //! Composes metadata header, description, discussion tree, and //! cross-references into a scrollable detail layout. Supports //! progressive hydration: metadata renders immediately while //! discussions load async in Phase 2. use ftui::core::geometry::Rect; use ftui::render::cell::{Cell, PackedRgba}; use ftui::render::drawing::Draw; use ftui::render::frame::Frame; use crate::clock::Clock; use crate::safety::{UrlPolicy, sanitize_for_terminal}; use crate::state::issue_detail::{DetailSection, IssueDetailState, IssueMetadata}; use crate::view::common::cross_ref::{CrossRefColors, render_cross_refs}; use crate::view::common::discussion_tree::{DiscussionTreeColors, render_discussion_tree}; // --------------------------------------------------------------------------- // Colors (Flexoki palette — will use injected Theme in a later phase) // --------------------------------------------------------------------------- const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2 const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); // green const RED: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // cyan const BG_SURFACE: PackedRgba = PackedRgba::rgb(0x28, 0x28, 0x24); // bg-2 const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2 const SELECTED_FG: PackedRgba = PackedRgba::rgb(0x10, 0x0F, 0x0F); // bg const SELECTED_BG: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx // --------------------------------------------------------------------------- // Color constructors // --------------------------------------------------------------------------- fn discussion_colors() -> DiscussionTreeColors { DiscussionTreeColors { author_fg: CYAN, timestamp_fg: TEXT_MUTED, body_fg: TEXT, system_fg: TEXT_MUTED, diff_path_fg: GREEN, resolved_fg: TEXT_MUTED, guide_fg: BORDER, selected_fg: SELECTED_FG, selected_bg: SELECTED_BG, expand_fg: ACCENT, } } fn cross_ref_colors() -> CrossRefColors { CrossRefColors { kind_fg: ACCENT, label_fg: TEXT, muted_fg: TEXT_MUTED, selected_fg: SELECTED_FG, selected_bg: SELECTED_BG, } } // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- /// Render the full issue detail screen. /// /// Layout: /// ```text /// Row 0: #42 Fix authentication flow (title bar) /// Row 1: opened | alice | backend, security (metadata row) /// Row 2: Milestone: v1.0 | Due: 2026-03-01 (optional) /// Row 3: ─────────────────────────────────── (separator) /// Row 4..N: Description text... (scrollable) /// ─────────────────────────────────── (separator) /// Discussions (3) (section header) /// ▶ alice: Fixed the login flow... (collapsed) /// ▼ bob: I think we should also... (expanded) /// bob: body line 1... /// ─────────────────────────────────── (separator) /// Cross References (section header) /// [MR] !10 Fix authentication MR /// ``` pub fn render_issue_detail( frame: &mut Frame<'_>, state: &IssueDetailState, area: Rect, clock: &dyn Clock, ) { if area.height < 3 || area.width < 10 { return; } let Some(ref meta) = state.metadata else { // No metadata yet — the loading spinner handles this. return; }; let max_x = area.x.saturating_add(area.width); let mut y = area.y; // --- Title bar --- y = render_title_bar(frame, meta, area.x, y, max_x); // --- Metadata row --- y = render_metadata_row(frame, meta, area.x, y, max_x); // --- Optional milestone / due date row --- if meta.milestone.is_some() || meta.due_date.is_some() { y = render_milestone_row(frame, meta, area.x, y, max_x); } // --- Separator --- y = render_separator(frame, area.x, y, area.width); let bottom = area.y.saturating_add(area.height); if y >= bottom { return; } // Remaining space is split between description, discussions, and cross-refs. let remaining = bottom.saturating_sub(y); // Compute section heights based on content. let desc_lines = count_description_lines(meta, area.width); let disc_count = state.discussions.len(); let xref_count = state.cross_refs.len(); let (desc_h, disc_h, xref_h) = allocate_sections(remaining, desc_lines, disc_count, xref_count); // --- Description section --- if desc_h > 0 { let desc_area = Rect::new(area.x, y, area.width, desc_h); let is_focused = state.active_section == DetailSection::Description; render_description(frame, meta, state.description_scroll, desc_area, is_focused); y += desc_h; } // --- Separator before discussions --- if (disc_h > 0 || xref_h > 0) && y < bottom { y = render_separator(frame, area.x, y, area.width); } // --- Discussions section --- if disc_h > 0 && y < bottom { let header_h = 1; let is_focused = state.active_section == DetailSection::Discussions; // Section header. render_section_header( frame, &format!("Discussions ({})", state.discussions.len()), area.x, y, max_x, is_focused, ); y += header_h; if !state.discussions_loaded { // Still loading. let style = Cell { fg: TEXT_MUTED, ..Cell::default() }; let _ = frame.print_text_clipped(area.x + 1, y, "Loading discussions...", style, max_x); y += 1; } else if state.discussions.is_empty() { let style = Cell { fg: TEXT_MUTED, ..Cell::default() }; let _ = frame.print_text_clipped(area.x + 1, y, "No discussions", style, max_x); y += 1; } else { let tree_height = disc_h.saturating_sub(header_h); if tree_height > 0 { let tree_area = Rect::new(area.x, y, area.width, tree_height); let rendered = render_discussion_tree( frame, &state.discussions, &state.tree_state, tree_area, &discussion_colors(), clock, ); y += rendered; } } } // --- Separator before cross-refs --- if xref_h > 0 && y < bottom { y = render_separator(frame, area.x, y, area.width); } // --- Cross-references section --- if xref_h > 0 && y < bottom { let is_focused = state.active_section == DetailSection::CrossRefs; render_section_header( frame, &format!("Cross References ({})", state.cross_refs.len()), area.x, y, max_x, is_focused, ); y += 1; if state.cross_refs.is_empty() { let style = Cell { fg: TEXT_MUTED, ..Cell::default() }; let _ = frame.print_text_clipped(area.x + 1, y, "No cross-references", style, max_x); } else { let refs_height = xref_h.saturating_sub(1); // minus header if refs_height > 0 { let refs_area = Rect::new(area.x, y, area.width, refs_height); let _ = render_cross_refs( frame, &state.cross_refs, &state.cross_ref_state, refs_area, &cross_ref_colors(), ); } } } } // --------------------------------------------------------------------------- // Sub-renderers // --------------------------------------------------------------------------- /// Render the issue title bar: `#42 Fix authentication flow` fn render_title_bar( frame: &mut Frame<'_>, meta: &IssueMetadata, x: u16, y: u16, max_x: u16, ) -> u16 { let iid_text = format!("#{} ", meta.iid); let iid_style = Cell { fg: ACCENT, ..Cell::default() }; let title_style = Cell { fg: TEXT, ..Cell::default() }; let cx = frame.print_text_clipped(x, y, &iid_text, iid_style, max_x); let safe_title = sanitize_for_terminal(&meta.title, UrlPolicy::Strip); let _ = frame.print_text_clipped(cx, y, &safe_title, title_style, max_x); y + 1 } /// Render the metadata row: `opened | alice | backend, security` fn render_metadata_row( frame: &mut Frame<'_>, meta: &IssueMetadata, x: u16, y: u16, max_x: u16, ) -> u16 { let state_fg = match meta.state.as_str() { "opened" => GREEN, "closed" => RED, _ => TEXT_MUTED, }; let state_style = Cell { fg: state_fg, ..Cell::default() }; let muted_style = Cell { fg: TEXT_MUTED, ..Cell::default() }; let author_style = Cell { fg: CYAN, ..Cell::default() }; let mut cx = frame.print_text_clipped(x, y, &meta.state, state_style, max_x); cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x); cx = frame.print_text_clipped(cx, y, &meta.author, author_style, max_x); if !meta.labels.is_empty() { cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x); let labels_text = meta.labels.join(", "); cx = frame.print_text_clipped(cx, y, &labels_text, muted_style, max_x); } if !meta.assignees.is_empty() { cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x); let assignees_text = format!("-> {}", meta.assignees.join(", ")); let _ = frame.print_text_clipped(cx, y, &assignees_text, muted_style, max_x); } y + 1 } /// Render optional milestone / due date row. fn render_milestone_row( frame: &mut Frame<'_>, meta: &IssueMetadata, x: u16, y: u16, max_x: u16, ) -> u16 { let muted = Cell { fg: TEXT_MUTED, ..Cell::default() }; let mut cx = x; if let Some(ref ms) = meta.milestone { cx = frame.print_text_clipped(cx, y, "Milestone: ", muted, max_x); let val_style = Cell { fg: TEXT, ..Cell::default() }; cx = frame.print_text_clipped(cx, y, ms, val_style, max_x); } if let Some(ref due) = meta.due_date { if cx > x { cx = frame.print_text_clipped(cx, y, " | ", muted, max_x); } cx = frame.print_text_clipped(cx, y, "Due: ", muted, max_x); let val_style = Cell { fg: TEXT, ..Cell::default() }; let _ = frame.print_text_clipped(cx, y, due, val_style, max_x); } y + 1 } /// Render a horizontal separator line. fn render_separator(frame: &mut Frame<'_>, x: u16, y: u16, width: u16) -> u16 { let sep_style = Cell { fg: BORDER, ..Cell::default() }; let line: String = "\u{2500}".repeat(width as usize); let _ = frame.print_text_clipped(x, y, &line, sep_style, x.saturating_add(width)); y + 1 } /// Render a section header with focus indicator. fn render_section_header( frame: &mut Frame<'_>, label: &str, x: u16, y: u16, max_x: u16, is_focused: bool, ) { if is_focused { let style = Cell { fg: SELECTED_FG, bg: SELECTED_BG, ..Cell::default() }; // Fill the row with selected background. frame.draw_rect_filled(Rect::new(x, y, max_x.saturating_sub(x), 1), style); let _ = frame.print_text_clipped(x, y, label, style, max_x); } else { let style = Cell { fg: ACCENT, ..Cell::default() }; let _ = frame.print_text_clipped(x, y, label, style, max_x); } } /// Render the description section. fn render_description( frame: &mut Frame<'_>, meta: &IssueMetadata, scroll: usize, area: Rect, _is_focused: bool, ) { let safe_desc = sanitize_for_terminal(&meta.description, UrlPolicy::Strip); let lines: Vec<&str> = safe_desc.lines().collect(); let text_style = Cell { fg: TEXT, ..Cell::default() }; let max_x = area.x.saturating_add(area.width); for (i, line) in lines .iter() .skip(scroll) .take(area.height as usize) .enumerate() { let y = area.y + i as u16; let _ = frame.print_text_clipped(area.x, y, line, text_style, max_x); } } /// Count the number of visible description lines for layout allocation. fn count_description_lines(meta: &IssueMetadata, _width: u16) -> usize { if meta.description.is_empty() { return 0; } // Rough estimate: count newlines. Proper word-wrap would need unicode width. meta.description.lines().count().max(1) } /// Allocate vertical space between description, discussions, and cross-refs. /// /// Priority: description gets min(content, 40%), discussions get most of the /// remaining space, cross-refs get a fixed portion at the bottom. fn allocate_sections( available: u16, desc_lines: usize, _disc_count: usize, xref_count: usize, ) -> (u16, u16, u16) { if available == 0 { return (0, 0, 0); } let total = available as usize; // Cross-refs: 1 header + count, max 25% of space. let xref_need = if xref_count > 0 { (1 + xref_count).min(total / 4) } else { 0 }; let after_xref = total.saturating_sub(xref_need); // Description: up to 40% of remaining, but at least the content lines. let desc_max = after_xref * 2 / 5; let desc_alloc = desc_lines.min(desc_max).min(after_xref); // Discussions: everything else. let disc_alloc = after_xref.saturating_sub(desc_alloc); (desc_alloc as u16, disc_alloc as u16, xref_need as u16) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::clock::FakeClock; use crate::message::EntityKey; use crate::state::issue_detail::{IssueDetailData, IssueMetadata}; use crate::view::common::cross_ref::{CrossRef, CrossRefKind}; use crate::view::common::discussion_tree::{DiscussionNode, NoteNode}; use ftui::render::grapheme_pool::GraphemePool; macro_rules! with_frame { ($width:expr, $height:expr, |$frame:ident| $body:block) => {{ let mut pool = GraphemePool::new(); let mut $frame = Frame::new($width, $height, &mut pool); $body }}; } fn sample_metadata() -> IssueMetadata { IssueMetadata { iid: 42, project_path: "group/project".into(), title: "Fix authentication flow".into(), description: "The login page has a bug.\nSteps to reproduce:\n1. Go to /login\n2. Enter credentials\n3. Click submit".into(), state: "opened".into(), author: "alice".into(), assignees: vec!["bob".into()], labels: vec!["backend".into(), "security".into()], milestone: Some("v1.0".into()), due_date: Some("2026-03-01".into()), created_at: 1_700_000_000_000, updated_at: 1_700_000_060_000, web_url: "https://gitlab.com/group/project/-/issues/42".into(), discussion_count: 2, } } fn sample_state_with_metadata() -> IssueDetailState { let mut state = IssueDetailState::default(); state.load_new(EntityKey::issue(1, 42)); state.apply_metadata(IssueDetailData { metadata: sample_metadata(), cross_refs: vec![CrossRef { kind: CrossRefKind::ClosingMr, entity_key: EntityKey::mr(1, 10), label: "Fix auth MR".into(), navigable: true, }], }); state } #[test] fn test_render_issue_detail_no_metadata_no_panic() { with_frame!(80, 24, |frame| { let state = IssueDetailState::default(); let clock = FakeClock::from_ms(1_700_000_000_000); render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock); }); } #[test] fn test_render_issue_detail_with_metadata_no_panic() { with_frame!(80, 24, |frame| { let state = sample_state_with_metadata(); let clock = FakeClock::from_ms(1_700_000_060_000); render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock); }); } #[test] fn test_render_issue_detail_tiny_area() { with_frame!(5, 2, |frame| { let state = sample_state_with_metadata(); let clock = FakeClock::from_ms(1_700_000_060_000); render_issue_detail(&mut frame, &state, Rect::new(0, 0, 5, 2), &clock); // Should bail early, no panic. }); } #[test] fn test_render_issue_detail_with_discussions() { with_frame!(80, 40, |frame| { let mut state = sample_state_with_metadata(); state.apply_discussions(vec![DiscussionNode { discussion_id: "d1".into(), notes: vec![NoteNode { author: "alice".into(), body: "I found the bug".into(), created_at: 1_700_000_000_000, is_system: false, is_diff_note: false, diff_file_path: None, diff_new_line: None, }], resolvable: false, resolved: false, }]); let clock = FakeClock::from_ms(1_700_000_060_000); render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 40), &clock); }); } #[test] fn test_render_issue_detail_discussions_loading() { with_frame!(80, 24, |frame| { let state = sample_state_with_metadata(); // discussions_loaded is false by default after load_new. assert!(!state.discussions_loaded); let clock = FakeClock::from_ms(1_700_000_060_000); render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock); }); } #[test] fn test_render_issue_detail_narrow_terminal() { with_frame!(30, 10, |frame| { let state = sample_state_with_metadata(); let clock = FakeClock::from_ms(1_700_000_060_000); render_issue_detail(&mut frame, &state, Rect::new(0, 0, 30, 10), &clock); }); } #[test] fn test_allocate_sections_empty() { assert_eq!(allocate_sections(0, 5, 3, 2), (0, 0, 0)); } #[test] fn test_allocate_sections_balanced() { let (d, disc, x) = allocate_sections(20, 5, 3, 2); assert!(d > 0); assert!(disc > 0); assert!(x > 0); assert_eq!(d + disc + x, 20); } #[test] fn test_allocate_sections_no_xrefs() { let (d, disc, x) = allocate_sections(20, 5, 3, 0); assert_eq!(x, 0); assert_eq!(d + disc, 20); } #[test] fn test_allocate_sections_no_discussions() { let (d, disc, x) = allocate_sections(20, 5, 0, 2); assert!(d > 0); assert_eq!(d + disc + x, 20); } #[test] fn test_count_description_lines() { let meta = sample_metadata(); let lines = count_description_lines(&meta, 80); assert_eq!(lines, 5); // 5 lines in the sample description } #[test] fn test_count_description_lines_empty() { let mut meta = sample_metadata(); meta.description = String::new(); assert_eq!(count_description_lines(&meta, 80), 0); } }