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.
980 lines
31 KiB
Rust
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(¬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<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("..."));
|
|
}
|
|
}
|