Files
gitlore/crates/lore-tui/src/view/common/discussion_tree.rs
teernisse 050e00345a feat(tui): Phase 2 detail screens — Issue Detail, MR Detail, discussion tree, cross-refs
Implements the remaining Phase 2 Core Screens:

- Discussion tree widget (view/common/discussion_tree.rs): DiscussionNode/NoteNode types,
  expand/collapse state, visual row flattening, format_relative_time with Clock trait
- Cross-reference widget (view/common/cross_ref.rs): CrossRefKind enum, navigable refs,
  badge rendering ([MR]/[REL]/[REF])
- Issue Detail (state + action + view): progressive hydration (metadata Phase 1,
  discussions Phase 2), section cycling, description scroll, sanitized GitLab content
- MR Detail (state + action + view): tab bar (Overview/Files/Discussions), file changes
  with change type indicators, branch info, draft/merge status, diff note support
- Message + update wiring: IssueDetailLoaded, MrDetailLoaded, DiscussionsLoaded handlers
  with TaskSupervisor stale-result guards

Closes bd-1d6z, bd-8ab7, bd-3t1b, bd-1cl9 (Phase 2 epic).
389 tests passing, clippy clean, fmt clean.
2026-02-18 15:37:23 -05:00

980 lines
31 KiB
Rust

#![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<NoteNode>,
/// 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<String>,
/// New line number for diff notes.
pub diff_new_line: Option<i64>,
}
// ---------------------------------------------------------------------------
// 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<String>,
}
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<i64>,
},
/// 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<VisualRow> {
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(&note.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<DiscussionNode> {
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::<Vec<_>>()
.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("..."));
}
}