#![allow(dead_code)] // Phase 2: consumed by Issue Detail + MR Detail screens //! Discussion tree widget for entity detail screens. //! //! Renders threaded conversations from GitLab issues/MRs. Discussions are //! top-level expandable nodes, with notes as children. Supports expand/collapse //! persistence, system note styling, and diff note file path rendering. use std::collections::HashSet; 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}; // --------------------------------------------------------------------------- // Data types // --------------------------------------------------------------------------- /// A single discussion thread (top-level node). #[derive(Debug, Clone)] pub struct DiscussionNode { /// GitLab discussion ID (used as expand/collapse key). pub discussion_id: String, /// Notes within this discussion, ordered by position. pub notes: Vec, /// Whether this discussion is resolvable (MR discussions only). pub resolvable: bool, /// Whether this discussion has been resolved. pub resolved: bool, } impl DiscussionNode { /// Summary line for collapsed display. fn summary(&self) -> String { let first = self.notes.first(); let author = first.map_or("unknown", |n| n.author.as_str()); let note_count = self.notes.len(); let resolved_tag = if self.resolved { " [resolved]" } else { "" }; if note_count == 1 { format!("{author}{resolved_tag}") } else { format!("{author} ({note_count} notes){resolved_tag}") } } /// First line of the first note body, sanitized and truncated. fn preview(&self, max_chars: usize) -> String { self.notes .first() .and_then(|n| n.body.lines().next()) .map(|line| { let sanitized = sanitize_for_terminal(line, UrlPolicy::Strip); if sanitized.len() > max_chars { let trunc = max_chars.saturating_sub(3); // Find the last valid char boundary at or before `trunc` // to avoid panicking on multi-byte UTF-8 (emoji, CJK). let safe_end = sanitized .char_indices() .take_while(|&(i, _)| i <= trunc) .last() .map_or(0, |(i, c)| i + c.len_utf8()); format!("{}...", &sanitized[..safe_end]) } else { sanitized } }) .unwrap_or_default() } } /// A single note within a discussion. #[derive(Debug, Clone)] pub struct NoteNode { /// Author username. pub author: String, /// Note body (markdown text from GitLab). pub body: String, /// Creation timestamp in milliseconds since epoch. pub created_at: i64, /// Whether this is a system-generated note. pub is_system: bool, /// Whether this is a diff/code review note. pub is_diff_note: bool, /// File path for diff notes. pub diff_file_path: Option, /// New line number for diff notes. pub diff_new_line: Option, } // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- /// Rendering state for the discussion tree. #[derive(Debug, Clone, Default)] pub struct DiscussionTreeState { /// Index of the selected discussion (0-based). pub selected: usize, /// First visible row index for scrolling. pub scroll_offset: usize, /// Set of expanded discussion IDs. pub expanded: HashSet, } impl DiscussionTreeState { /// Move selection down. pub fn select_next(&mut self, total: usize) { if total > 0 && self.selected < total - 1 { self.selected += 1; } } /// Move selection up. pub fn select_prev(&mut self) { self.selected = self.selected.saturating_sub(1); } /// Toggle expand/collapse for the selected discussion. pub fn toggle_selected(&mut self, discussions: &[DiscussionNode]) { if let Some(d) = discussions.get(self.selected) { let id = &d.discussion_id; if self.expanded.contains(id) { self.expanded.remove(id); } else { self.expanded.insert(id.clone()); } } } /// Whether a discussion is expanded. #[must_use] pub fn is_expanded(&self, discussion_id: &str) -> bool { self.expanded.contains(discussion_id) } } // --------------------------------------------------------------------------- // Colors // --------------------------------------------------------------------------- /// Color scheme for discussion tree rendering. pub struct DiscussionTreeColors { /// Author name foreground. pub author_fg: PackedRgba, /// Timestamp foreground. pub timestamp_fg: PackedRgba, /// Note body foreground. pub body_fg: PackedRgba, /// System note foreground (muted). pub system_fg: PackedRgba, /// Diff file path foreground. pub diff_path_fg: PackedRgba, /// Resolved indicator foreground. pub resolved_fg: PackedRgba, /// Tree guide characters. pub guide_fg: PackedRgba, /// Selected discussion background. pub selected_fg: PackedRgba, /// Selected discussion background. pub selected_bg: PackedRgba, /// Expand/collapse indicator. pub expand_fg: PackedRgba, } // --------------------------------------------------------------------------- // Relative time formatting // --------------------------------------------------------------------------- /// Format a timestamp as a human-readable relative time string. /// /// Uses the provided `Clock` for deterministic rendering in tests. #[must_use] pub fn format_relative_time(epoch_ms: i64, clock: &dyn Clock) -> String { let now_ms = clock.now_ms(); let diff_ms = now_ms.saturating_sub(epoch_ms); if diff_ms < 0 { return "just now".to_string(); } let seconds = diff_ms / 1_000; let minutes = seconds / 60; let hours = minutes / 60; let days = hours / 24; let weeks = days / 7; let months = days / 30; if seconds < 60 { "just now".to_string() } else if minutes < 60 { format!("{minutes}m ago") } else if hours < 24 { format!("{hours}h ago") } else if days < 7 { format!("{days}d ago") } else if weeks < 4 { format!("{weeks}w ago") } else { format!("{months}mo ago") } } // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- /// Maximum indent depth for nested content (notes within discussions). const INDENT: u16 = 4; /// Render a discussion tree within the given area. /// /// Returns the number of rows consumed. /// /// Layout: /// ```text /// > alice (3 notes) [resolved] <- collapsed discussion /// First line of note body preview... /// /// v bob (2 notes) <- expanded discussion /// | bob ยท 3h ago /// | This is the first note body... /// | /// | alice ยท 1h ago <- diff note /// | diff src/auth.rs:42 /// | Code review comment about... /// ``` pub fn render_discussion_tree( frame: &mut Frame<'_>, discussions: &[DiscussionNode], state: &DiscussionTreeState, area: Rect, colors: &DiscussionTreeColors, clock: &dyn Clock, ) -> u16 { if discussions.is_empty() || area.height == 0 || area.width < 15 { return 0; } let max_x = area.x.saturating_add(area.width); let mut y = area.y; let y_max = area.y.saturating_add(area.height); // Pre-compute all visual rows to support scroll offset. let rows = compute_visual_rows_with_clock( discussions, state, max_x.saturating_sub(area.x) as usize, clock, ); // Apply scroll offset. let visible_rows = rows .iter() .skip(state.scroll_offset) .take(area.height as usize); for row in visible_rows { if y >= y_max { break; } match row { VisualRow::DiscussionHeader { disc_idx, expanded, summary, preview, } => { let is_selected = *disc_idx == state.selected; // Background fill for selected. if is_selected { frame.draw_rect_filled( Rect::new(area.x, y, area.width, 1), Cell { fg: colors.selected_fg, bg: colors.selected_bg, ..Cell::default() }, ); } let style = if is_selected { Cell { fg: colors.selected_fg, bg: colors.selected_bg, ..Cell::default() } } else { Cell { fg: colors.author_fg, ..Cell::default() } }; let indicator = if *expanded { "v " } else { "> " }; let mut x = frame.print_text_clipped(area.x, y, indicator, style, max_x); x = frame.print_text_clipped(x, y, summary, style, max_x); // Show preview on same line for collapsed. if !expanded && !preview.is_empty() { let preview_style = if is_selected { style } else { Cell { fg: colors.timestamp_fg, ..Cell::default() } }; x = frame.print_text_clipped(x, y, " - ", preview_style, max_x); let _ = frame.print_text_clipped(x, y, preview, preview_style, max_x); } y += 1; } VisualRow::NoteHeader { author, relative_time, is_system, .. } => { let style = if *is_system { Cell { fg: colors.system_fg, ..Cell::default() } } else { Cell { fg: colors.author_fg, ..Cell::default() } }; let guide_style = Cell { fg: colors.guide_fg, ..Cell::default() }; let indent_x = area.x.saturating_add(INDENT); let mut x = frame.print_text_clipped(area.x, y, " | ", guide_style, max_x); x = frame.print_text_clipped(x.max(indent_x), y, author, style, max_x); let time_style = Cell { fg: colors.timestamp_fg, ..Cell::default() }; x = frame.print_text_clipped(x, y, " ยท ", time_style, max_x); let _ = frame.print_text_clipped(x, y, relative_time, time_style, max_x); y += 1; } VisualRow::DiffPath { file_path, line } => { let guide_style = Cell { fg: colors.guide_fg, ..Cell::default() }; let path_style = Cell { fg: colors.diff_path_fg, ..Cell::default() }; let mut x = frame.print_text_clipped(area.x, y, " | ", guide_style, max_x); let indent_x = area.x.saturating_add(INDENT); x = x.max(indent_x); let location = match line { Some(l) => format!("diff {file_path}:{l}"), None => format!("diff {file_path}"), }; let _ = frame.print_text_clipped(x, y, &location, path_style, max_x); y += 1; } VisualRow::BodyLine { text, is_system } => { let guide_style = Cell { fg: colors.guide_fg, ..Cell::default() }; let body_style = if *is_system { Cell { fg: colors.system_fg, ..Cell::default() } } else { Cell { fg: colors.body_fg, ..Cell::default() } }; let mut x = frame.print_text_clipped(area.x, y, " | ", guide_style, max_x); let indent_x = area.x.saturating_add(INDENT); x = x.max(indent_x); let _ = frame.print_text_clipped(x, y, text, body_style, max_x); y += 1; } VisualRow::Separator => { let guide_style = Cell { fg: colors.guide_fg, ..Cell::default() }; let _ = frame.print_text_clipped(area.x, y, " |", guide_style, max_x); y += 1; } } } y.saturating_sub(area.y) } // --------------------------------------------------------------------------- // Visual row computation // --------------------------------------------------------------------------- /// Pre-computed visual row for the discussion tree. /// /// We flatten the tree into rows to support scroll offset correctly. #[derive(Debug)] enum VisualRow { /// Discussion header (collapsed or expanded). DiscussionHeader { disc_idx: usize, expanded: bool, summary: String, preview: String, }, /// Note author + timestamp line. NoteHeader { author: String, relative_time: String, is_system: bool, }, /// Diff note file path line. DiffPath { file_path: String, line: Option, }, /// Note body text line. BodyLine { text: String, is_system: bool }, /// Blank separator between notes. Separator, } /// Maximum body lines shown per note to prevent one huge note from /// consuming the entire viewport. const MAX_BODY_LINES: usize = 10; /// Compute visual rows with relative timestamps from the clock. fn compute_visual_rows_with_clock( discussions: &[DiscussionNode], state: &DiscussionTreeState, available_width: usize, clock: &dyn Clock, ) -> Vec { let mut rows = Vec::new(); let preview_max = available_width.saturating_sub(40).max(20); for (idx, disc) in discussions.iter().enumerate() { let expanded = state.is_expanded(&disc.discussion_id); rows.push(VisualRow::DiscussionHeader { disc_idx: idx, expanded, summary: disc.summary(), preview: if expanded { String::new() } else { disc.preview(preview_max) }, }); if expanded { for (note_idx, note) in disc.notes.iter().enumerate() { if note_idx > 0 { rows.push(VisualRow::Separator); } rows.push(VisualRow::NoteHeader { author: note.author.clone(), relative_time: format_relative_time(note.created_at, clock), is_system: note.is_system, }); if note.is_diff_note && let Some(ref path) = note.diff_file_path { rows.push(VisualRow::DiffPath { file_path: path.clone(), line: note.diff_new_line, }); } let sanitized = sanitize_for_terminal(¬e.body, UrlPolicy::Strip); for (line_idx, line) in sanitized.lines().enumerate() { if line_idx >= MAX_BODY_LINES { rows.push(VisualRow::BodyLine { text: "...".to_string(), is_system: note.is_system, }); break; } rows.push(VisualRow::BodyLine { text: line.to_string(), is_system: note.is_system, }); } } } } rows } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::clock::FakeClock; 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_note(author: &str, body: &str, created_at: i64) -> NoteNode { NoteNode { author: author.into(), body: body.into(), created_at, is_system: false, is_diff_note: false, diff_file_path: None, diff_new_line: None, } } fn system_note(body: &str, created_at: i64) -> NoteNode { NoteNode { author: "system".into(), body: body.into(), created_at, is_system: true, is_diff_note: false, diff_file_path: None, diff_new_line: None, } } fn diff_note(author: &str, body: &str, path: &str, line: i64, created_at: i64) -> NoteNode { NoteNode { author: author.into(), body: body.into(), created_at, is_system: false, is_diff_note: true, diff_file_path: Some(path.into()), diff_new_line: Some(line), } } fn sample_discussions() -> Vec { vec![ DiscussionNode { discussion_id: "disc-1".into(), notes: vec![ sample_note("alice", "This looks good overall", 1_700_000_000_000), sample_note("bob", "Agreed, but one concern", 1_700_000_060_000), ], resolvable: false, resolved: false, }, DiscussionNode { discussion_id: "disc-2".into(), notes: vec![diff_note( "charlie", "This function needs error handling", "src/auth.rs", 42, 1_700_000_120_000, )], resolvable: true, resolved: true, }, DiscussionNode { discussion_id: "disc-3".into(), notes: vec![system_note("changed the description", 1_700_000_180_000)], resolvable: false, resolved: false, }, ] } fn test_colors() -> DiscussionTreeColors { DiscussionTreeColors { author_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3), timestamp_fg: PackedRgba::rgb(0x87, 0x87, 0x80), body_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3), system_fg: PackedRgba::rgb(0x6F, 0x6E, 0x69), diff_path_fg: PackedRgba::rgb(0x87, 0x96, 0x6B), resolved_fg: PackedRgba::rgb(0x87, 0x96, 0x6B), guide_fg: PackedRgba::rgb(0x40, 0x40, 0x3C), selected_fg: PackedRgba::rgb(0x10, 0x0F, 0x0F), selected_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3), expand_fg: PackedRgba::rgb(0xDA, 0x70, 0x2C), } } // Clock set to 1h after the last sample note. fn test_clock() -> FakeClock { FakeClock::from_ms(1_700_000_180_000 + 3_600_000) } #[test] fn test_format_relative_time_just_now() { let clock = FakeClock::from_ms(1_000_000); assert_eq!(format_relative_time(1_000_000, &clock), "just now"); assert_eq!(format_relative_time(999_990, &clock), "just now"); } #[test] fn test_format_relative_time_minutes() { let clock = FakeClock::from_ms(1_000_000 + 5 * 60 * 1_000); assert_eq!(format_relative_time(1_000_000, &clock), "5m ago"); } #[test] fn test_format_relative_time_hours() { let clock = FakeClock::from_ms(1_000_000 + 3 * 3_600 * 1_000); assert_eq!(format_relative_time(1_000_000, &clock), "3h ago"); } #[test] fn test_format_relative_time_days() { let clock = FakeClock::from_ms(1_000_000 + 2 * 86_400 * 1_000); assert_eq!(format_relative_time(1_000_000, &clock), "2d ago"); } #[test] fn test_format_relative_time_weeks() { let clock = FakeClock::from_ms(1_000_000 + 14 * 86_400 * 1_000); assert_eq!(format_relative_time(1_000_000, &clock), "2w ago"); } #[test] fn test_format_relative_time_months() { let clock = FakeClock::from_ms(1_000_000 + 60 * 86_400 * 1_000); assert_eq!(format_relative_time(1_000_000, &clock), "2mo ago"); } #[test] fn test_discussion_node_summary() { let disc = DiscussionNode { discussion_id: "d1".into(), notes: vec![ sample_note("alice", "body", 0), sample_note("bob", "reply", 1000), ], resolvable: false, resolved: false, }; assert_eq!(disc.summary(), "alice (2 notes)"); } #[test] fn test_discussion_node_summary_single() { let disc = DiscussionNode { discussion_id: "d1".into(), notes: vec![sample_note("alice", "body", 0)], resolvable: false, resolved: false, }; assert_eq!(disc.summary(), "alice"); } #[test] fn test_discussion_node_summary_resolved() { let disc = DiscussionNode { discussion_id: "d1".into(), notes: vec![sample_note("alice", "body", 0)], resolvable: true, resolved: true, }; assert_eq!(disc.summary(), "alice [resolved]"); } #[test] fn test_discussion_node_preview() { let disc = DiscussionNode { discussion_id: "d1".into(), notes: vec![sample_note("alice", "First line\nSecond line", 0)], resolvable: false, resolved: false, }; assert_eq!(disc.preview(50), "First line"); } #[test] fn test_discussion_tree_state_navigation() { let mut state = DiscussionTreeState::default(); assert_eq!(state.selected, 0); state.select_next(3); assert_eq!(state.selected, 1); state.select_next(3); assert_eq!(state.selected, 2); state.select_next(3); assert_eq!(state.selected, 2); state.select_prev(); assert_eq!(state.selected, 1); state.select_prev(); assert_eq!(state.selected, 0); state.select_prev(); assert_eq!(state.selected, 0); } #[test] fn test_discussion_tree_state_toggle() { let discussions = sample_discussions(); let mut state = DiscussionTreeState::default(); assert!(!state.is_expanded("disc-1")); state.toggle_selected(&discussions); assert!(state.is_expanded("disc-1")); state.toggle_selected(&discussions); assert!(!state.is_expanded("disc-1")); } #[test] fn test_render_discussion_tree_collapsed_no_panic() { with_frame!(80, 20, |frame| { let discussions = sample_discussions(); let state = DiscussionTreeState::default(); let clock = test_clock(); let rows = render_discussion_tree( &mut frame, &discussions, &state, Rect::new(0, 0, 80, 20), &test_colors(), &clock, ); // 3 discussions, all collapsed = 3 rows. assert_eq!(rows, 3); }); } #[test] fn test_render_discussion_tree_expanded_no_panic() { with_frame!(80, 30, |frame| { let discussions = sample_discussions(); let mut state = DiscussionTreeState::default(); state.expanded.insert("disc-1".into()); let clock = test_clock(); let rows = render_discussion_tree( &mut frame, &discussions, &state, Rect::new(0, 0, 80, 30), &test_colors(), &clock, ); // disc-1 expanded: header + 2 notes (each: header + body line) + separator between // = 1 + (1+1) + 1 + (1+1) = 6 rows from disc-1 // disc-2 collapsed: 1 row // disc-3 collapsed: 1 row // Total: 8 assert!(rows >= 6); // At least disc-1 content + 2 collapsed. }); } #[test] fn test_render_discussion_tree_empty() { with_frame!(80, 20, |frame| { let state = DiscussionTreeState::default(); let clock = test_clock(); let rows = render_discussion_tree( &mut frame, &[], &state, Rect::new(0, 0, 80, 20), &test_colors(), &clock, ); assert_eq!(rows, 0); }); } #[test] fn test_render_discussion_tree_tiny_area() { with_frame!(10, 2, |frame| { let discussions = sample_discussions(); let state = DiscussionTreeState::default(); let clock = test_clock(); let rows = render_discussion_tree( &mut frame, &discussions, &state, Rect::new(0, 0, 10, 2), &test_colors(), &clock, ); // Too narrow (< 15), should bail. assert_eq!(rows, 0); }); } #[test] fn test_render_discussion_tree_with_diff_note() { with_frame!(80, 30, |frame| { let discussions = vec![DiscussionNode { discussion_id: "diff-disc".into(), notes: vec![diff_note( "reviewer", "Add error handling here", "src/main.rs", 42, 1_700_000_000_000, )], resolvable: true, resolved: false, }]; let mut state = DiscussionTreeState::default(); state.expanded.insert("diff-disc".into()); let clock = test_clock(); let rows = render_discussion_tree( &mut frame, &discussions, &state, Rect::new(0, 0, 80, 30), &test_colors(), &clock, ); // header + note header + diff path + body line = 4 assert!(rows >= 3); }); } #[test] fn test_render_discussion_tree_system_note() { with_frame!(80, 20, |frame| { let discussions = vec![DiscussionNode { discussion_id: "sys-disc".into(), notes: vec![system_note("changed the description", 1_700_000_000_000)], resolvable: false, resolved: false, }]; let mut state = DiscussionTreeState::default(); state.expanded.insert("sys-disc".into()); let clock = test_clock(); let rows = render_discussion_tree( &mut frame, &discussions, &state, Rect::new(0, 0, 80, 20), &test_colors(), &clock, ); assert!(rows >= 2); }); } #[test] fn test_compute_visual_rows_collapsed() { let discussions = sample_discussions(); let state = DiscussionTreeState::default(); let clock = test_clock(); let rows = compute_visual_rows_with_clock(&discussions, &state, 80, &clock); // 3 collapsed headers. assert_eq!(rows.len(), 3); assert!(matches!( rows[0], VisualRow::DiscussionHeader { expanded: false, .. } )); } #[test] fn test_compute_visual_rows_expanded() { let discussions = sample_discussions(); let mut state = DiscussionTreeState::default(); state.expanded.insert("disc-1".into()); let clock = test_clock(); let rows = compute_visual_rows_with_clock(&discussions, &state, 80, &clock); // disc-1: header + note1 (header + body) + separator + note2 (header + body) = 6 // disc-2: 1 header // disc-3: 1 header // Total: 8 assert!(rows.len() >= 6); assert!(matches!( rows[0], VisualRow::DiscussionHeader { expanded: true, .. } )); } #[test] fn test_long_body_truncation() { let long_body = (0..20) .map(|i| format!("Line {i} of a very long discussion note")) .collect::>() .join("\n"); let discussions = vec![DiscussionNode { discussion_id: "long".into(), notes: vec![sample_note("alice", &long_body, 1_700_000_000_000)], resolvable: false, resolved: false, }]; let mut state = DiscussionTreeState::default(); state.expanded.insert("long".into()); let clock = test_clock(); let rows = compute_visual_rows_with_clock(&discussions, &state, 80, &clock); // Header + note header + MAX_BODY_LINES + 1 ("...") = 1 + 1 + 10 + 1 = 13 let body_lines: Vec<_> = rows .iter() .filter(|r| matches!(r, VisualRow::BodyLine { .. })) .collect(); // Should cap at MAX_BODY_LINES + 1 (for the "..." truncation line). assert!(body_lines.len() <= MAX_BODY_LINES + 1); } #[test] fn test_preview_multibyte_utf8_no_panic() { // Emoji are 4 bytes each. Truncating at a byte boundary that falls // inside a multi-byte char must not panic. let disc = DiscussionNode { discussion_id: "d-utf8".into(), notes: vec![sample_note( "alice", "Hello ๐ŸŒ๐ŸŒŽ๐ŸŒ world of emoji ๐ŸŽ‰๐ŸŽŠ๐ŸŽˆ", 0, )], resolvable: false, resolved: false, }; // max_chars=10 would land inside the first emoji's bytes. let preview = disc.preview(10); assert!(preview.ends_with("...")); assert!(preview.len() <= 20); // char-bounded + "..." // Edge: max_chars smaller than a single multi-byte char. let disc2 = DiscussionNode { discussion_id: "d-utf8-2".into(), notes: vec![sample_note("bob", "๐ŸŒ๐ŸŒŽ๐ŸŒ", 0)], resolvable: false, resolved: false, }; let preview2 = disc2.preview(3); assert!(preview2.ends_with("...")); } }