use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
use crate::app::App;
use crate::theme::Theme;
use crate::ui::{markdown, styles};
use travelagent_core::forge::{RemoteComment, ReviewThread};
const DEFAULT_BODY_WIDTH: usize = 80;
pub fn top_level_thread_indices(app: &App) -> Vec<usize> {
let Some(r) = app.remote() else {
return Vec::new();
};
r.remote_comments
.iter()
.enumerate()
.filter(|(_, c)| c.in_reply_to.is_none())
.map(|(idx, _)| idx)
.collect()
}
pub fn thread_is_resolved(threads: &[ReviewThread], root_comment_id: u64) -> Option<bool> {
threads
.iter()
.find(|t| t.root_comment_id == root_comment_id)
.map(|t| t.is_resolved)
}
pub fn selected_thread_id(app: &App) -> Option<String> {
let r = app.remote()?;
let tops = top_level_thread_indices(app);
let idx = *tops.get(r.conversation_cursor)?;
let root = r.remote_comments.get(idx)?;
r.review_threads
.iter()
.find(|t| t.root_comment_id == root.id)
.map(|t| t.id.clone())
}
pub fn selected_root_comment_id(app: &App) -> Option<u64> {
let r = app.remote()?;
let tops = top_level_thread_indices(app);
let idx = *tops.get(r.conversation_cursor)?;
r.remote_comments.get(idx).map(|c| c.id)
}
pub fn clamp_conversation_cursor(app: &mut App) {
let tops = top_level_thread_indices(app);
let Some(r) = app.remote_mut() else {
return;
};
if tops.is_empty() {
r.conversation_cursor = 0;
} else if r.conversation_cursor >= tops.len() {
r.conversation_cursor = tops.len() - 1;
}
}
fn header_line(
label: String,
is_resolved: Option<bool>,
theme_pending: ratatui::style::Color,
theme_reviewed: ratatui::style::Color,
) -> Line<'static> {
let (marker, marker_color) = match is_resolved {
Some(true) => ("[\u{2713}] ", theme_reviewed),
Some(false) => ("[ ] ", theme_pending),
None => ("[ ] ", theme_pending),
};
Line::from(vec![
Span::styled(marker.to_string(), Style::default().fg(marker_color)),
Span::styled(label, Style::default().add_modifier(Modifier::BOLD)),
])
}
#[derive(Debug, Clone)]
pub struct ConversationLayout {
pub lines: Vec<Line<'static>>,
pub thread_header_indices: Vec<usize>,
}
pub fn layout(app: &App, width: usize) -> ConversationLayout {
let theme = &app.theme;
let mut lines: Vec<Line<'static>> = Vec::new();
let mut thread_header_indices: Vec<usize> = Vec::new();
let remote_comments: &[RemoteComment] = app
.remote()
.map(|r| r.remote_comments.as_slice())
.unwrap_or(&[]);
let review_threads: &[ReviewThread] = app
.remote()
.map(|r| r.review_threads.as_slice())
.unwrap_or(&[]);
if remote_comments.is_empty() {
lines.push(Line::from(Span::styled(
" No comments yet".to_string(),
styles::dim_style(theme),
)));
return ConversationLayout {
lines,
thread_header_indices,
};
}
let top_level: Vec<&RemoteComment> = remote_comments
.iter()
.filter(|c| c.in_reply_to.is_none())
.collect();
for comment in &top_level {
let timestamp = comment.created_at.format("%Y-%m-%d %H:%M");
let header_label = format!(" {} ({}):", comment.author, timestamp);
let is_resolved = thread_is_resolved(review_threads, comment.id);
let header_idx = lines.len();
thread_header_indices.push(header_idx);
lines.push(header_line(
header_label,
is_resolved,
theme.pending,
theme.reviewed,
));
append_body_lines(
&mut lines,
&comment.body,
theme,
width,
4,
app.markdown_rendering_enabled,
);
if let Some(ref path) = comment.path {
let loc = if let Some(line) = comment.line {
format!(" [{path}:{line}]")
} else {
format!(" [{path}]")
};
lines.push(Line::from(Span::styled(loc, styles::dim_style(theme))));
}
let replies: Vec<&RemoteComment> = remote_comments
.iter()
.filter(|c| c.in_reply_to == Some(comment.id))
.collect();
for reply in &replies {
let reply_ts = reply.created_at.format("%Y-%m-%d %H:%M");
lines.push(Line::from(vec![Span::styled(
format!(" \u{21b3} {} ({}):", reply.author, reply_ts),
Style::default().add_modifier(Modifier::BOLD),
)]));
append_body_lines(
&mut lines,
&reply.body,
theme,
width,
6,
app.markdown_rendering_enabled,
);
}
lines.push(Line::from(""));
}
ConversationLayout {
lines,
thread_header_indices,
}
}
fn append_body_lines(
lines: &mut Vec<Line<'static>>,
body: &str,
theme: &Theme,
width: usize,
indent: usize,
markdown_enabled: bool,
) {
let available = width.saturating_sub(indent);
let prefix: String = " ".repeat(indent);
let file_ref_style = Style::default().fg(theme.file_ref);
if markdown_enabled {
let rendered = markdown::render_markdown(body, theme, available);
let rendered = markdown::highlight_file_refs(rendered, file_ref_style, theme.markdown_code);
for mut rendered_line in rendered {
let mut spans = Vec::with_capacity(rendered_line.spans.len() + 1);
spans.push(Span::raw(prefix.clone()));
spans.append(&mut rendered_line.spans);
lines.push(Line::from(spans));
}
} else {
for body_line in body.lines() {
let raw = Line::from(format!("{prefix}{body_line}"));
let promoted =
markdown::highlight_file_refs(vec![raw], file_ref_style, theme.markdown_code);
lines.extend(promoted);
}
}
}
pub fn auto_scroll_for_cursor(
current_scroll: usize,
total_lines: usize,
viewport_height: usize,
thread_header_indices: &[usize],
cursor: usize,
) -> usize {
let max_scroll = total_lines.saturating_sub(viewport_height);
let header = match thread_header_indices.get(cursor) {
Some(&h) => h,
None => return current_scroll.min(max_scroll),
};
let mut scroll = current_scroll.min(max_scroll);
if header < scroll {
scroll = header;
} else if viewport_height > 0 && header >= scroll + viewport_height {
scroll = header + 1 - viewport_height;
}
scroll.min(max_scroll)
}
pub fn render(frame: &mut Frame, app: &mut App, area: Rect) {
let width = (area.width as usize).max(DEFAULT_BODY_WIDTH.min(20));
let layout = layout(app, width);
let total_lines = layout.lines.len();
let viewport_height = area.height as usize;
let panel_style = styles::panel_style(&app.theme);
let selected = styles::selected_style(&app.theme);
let selected_header = if let Some(r) = app.remote_mut() {
r.conversation_scroll = auto_scroll_for_cursor(
r.conversation_scroll,
total_lines,
viewport_height,
&layout.thread_header_indices,
r.conversation_cursor,
);
let scroll = r.conversation_scroll;
let sel = layout
.thread_header_indices
.get(r.conversation_cursor)
.copied();
(scroll, sel)
} else {
(0, None)
};
let (scroll, sel_header) = selected_header;
let visible: Vec<Line> = layout
.lines
.into_iter()
.enumerate()
.skip(scroll)
.take(viewport_height)
.map(|(idx, line)| {
if Some(idx) == sel_header {
line.style(selected)
} else {
line
}
})
.collect();
let paragraph = Paragraph::new(visible).style(panel_style);
frame.render_widget(paragraph, area);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::App;
use crate::theme::Theme;
use chrono::Utc;
use travelagent_core::forge::{PrId, RemoteComment, ReviewThread};
fn build_test_app() -> App {
App::new_remote(
Theme::dark(),
None,
false,
Vec::new(),
"Test PR".to_string(),
1,
"owner",
"repo",
crate::test_support::runtime_handle(),
None,
PrId {
owner: "owner".to_string(),
repo: "repo".to_string(),
number: 1,
},
)
.expect("new_remote builds in tests")
}
fn top_comment(id: u64, body: &str) -> RemoteComment {
RemoteComment {
id,
author: "alice".to_string(),
body: body.to_string(),
path: None,
line: None,
side: None,
created_at: Utc::now(),
in_reply_to: None,
}
}
fn reply(id: u64, parent: u64, body: &str) -> RemoteComment {
RemoteComment {
id,
author: "bob".to_string(),
body: body.to_string(),
path: None,
line: None,
side: None,
created_at: Utc::now(),
in_reply_to: Some(parent),
}
}
fn thread(id: &str, root: u64, resolved: bool) -> ReviewThread {
ReviewThread {
id: id.to_string(),
is_resolved: resolved,
root_comment_id: root,
}
}
#[test]
fn top_level_thread_indices_filters_replies() {
let mut app = build_test_app();
app.remote_mut().unwrap().remote_comments = vec![
top_comment(1, "first"),
reply(2, 1, "reply to first"),
top_comment(3, "second"),
];
let tops = top_level_thread_indices(&app);
assert_eq!(tops, vec![0, 2]);
}
#[test]
fn selected_thread_id_returns_matching_thread() {
let mut app = build_test_app();
{
let r = app.remote_mut().unwrap();
r.remote_comments = vec![top_comment(10, "hi"), top_comment(20, "there")];
r.review_threads = vec![thread("t10", 10, false), thread("t20", 20, true)];
r.conversation_cursor = 1;
}
assert_eq!(selected_thread_id(&app), Some("t20".to_string()));
app.remote_mut().unwrap().conversation_cursor = 0;
assert_eq!(selected_thread_id(&app), Some("t10".to_string()));
}
#[test]
fn selected_thread_id_none_when_empty() {
let app = build_test_app();
assert!(selected_thread_id(&app).is_none());
}
#[test]
fn thread_is_resolved_handles_missing() {
let threads = vec![thread("t1", 1, true), thread("t2", 2, false)];
assert_eq!(thread_is_resolved(&threads, 1), Some(true));
assert_eq!(thread_is_resolved(&threads, 2), Some(false));
assert_eq!(thread_is_resolved(&threads, 3), None);
}
#[test]
fn clamp_conversation_cursor_keeps_in_bounds() {
let mut app = build_test_app();
{
let r = app.remote_mut().unwrap();
r.remote_comments = vec![top_comment(1, "a"), top_comment(2, "b")];
r.conversation_cursor = 99;
}
clamp_conversation_cursor(&mut app);
assert_eq!(app.remote().unwrap().conversation_cursor, 1);
{
let r = app.remote_mut().unwrap();
r.remote_comments.clear();
r.conversation_cursor = 99;
}
clamp_conversation_cursor(&mut app);
assert_eq!(app.remote().unwrap().conversation_cursor, 0);
}
#[test]
fn layout_produces_header_for_each_top_level_thread() {
let mut app = build_test_app();
app.remote_mut().unwrap().remote_comments = vec![
top_comment(1, "first"),
reply(2, 1, "reply"),
top_comment(3, "second"),
];
let layout = layout(&app, 80);
assert_eq!(layout.thread_header_indices.len(), 2);
assert_eq!(layout.thread_header_indices[0], 0);
assert!(layout.thread_header_indices[1] > layout.thread_header_indices[0]);
}
#[test]
fn layout_empty_when_no_comments() {
let app = build_test_app();
let layout = layout(&app, 80);
assert_eq!(layout.thread_header_indices.len(), 0);
assert_eq!(layout.lines.len(), 1); }
#[test]
fn layout_renders_resolved_marker() {
let mut app = build_test_app();
{
let r = app.remote_mut().unwrap();
r.remote_comments = vec![top_comment(1, "first"), top_comment(2, "second")];
r.review_threads = vec![thread("t1", 1, true), thread("t2", 2, false)];
}
let layout = layout(&app, 80);
let first_header = &layout.lines[layout.thread_header_indices[0]];
let first_text: String = first_header
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(first_text.starts_with("[\u{2713}] "));
let second_header = &layout.lines[layout.thread_header_indices[1]];
let second_text: String = second_header
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(second_text.starts_with("[ ] "));
}
#[test]
fn auto_scroll_keeps_header_in_viewport() {
let headers = vec![0, 10, 20];
let scroll = auto_scroll_for_cursor(0, 30, 5, &headers, 2);
assert_eq!(scroll, 16);
let scroll = auto_scroll_for_cursor(18, 30, 5, &headers, 0);
assert_eq!(scroll, 0);
let scroll = auto_scroll_for_cursor(8, 30, 5, &headers, 1);
assert_eq!(scroll, 8);
}
#[test]
fn auto_scroll_clamps_at_max() {
let headers = vec![0];
let scroll = auto_scroll_for_cursor(100, 10, 5, &headers, 0);
assert_eq!(scroll, 0);
}
}