diff --git a/src/ui/components/issue_conversation.rs b/src/ui/components/issue_conversation.rs index c4da2b5..2a99288 100644 --- a/src/ui/components/issue_conversation.rs +++ b/src/ui/components/issue_conversation.rs @@ -12,18 +12,19 @@ use rat_cursor::HasScreenCursor; use rat_widget::{ event::{HandleEvent, Outcome, TextOutcome, ct_event}, focus::{FocusBuilder, FocusFlag, HasFocus, Navigation}, + line_number::{LineNumberState, LineNumbers}, list::{ListState, selection::RowSelection}, paragraph::{Paragraph, ParagraphState}, textarea::{TextArea, TextAreaState, TextWrap}, }; use ratatui::{ buffer::Buffer, - layout::Rect, - style::{Color, Modifier, Style, Stylize}, + layout::{Rect, Spacing}, + style::{Color, Modifier, Style}, text::{Line, Span, Text}, - widgets::{self, Block, ListItem, StatefulWidget, Widget}, + widgets::{self, Block, Borders, ListItem, Padding, StatefulWidget, Widget}, }; -use ratatui_macros::{horizontal, line, span, vertical}; +use ratatui_macros::{horizontal, line, vertical}; use std::{ collections::{HashMap, HashSet}, sync::{Arc, OnceLock, RwLock}, @@ -170,7 +171,7 @@ pub struct TimelineEventView { } impl TimelineEventView { - fn from_api(event: TimelineEvent, fallback_id: u64) -> Option { + pub(crate) fn from_api(event: TimelineEvent, fallback_id: u64) -> Option { if matches!( event.event, IssueEvent::Commented | IssueEvent::LineCommented | IssueEvent::CommentDeleted @@ -210,6 +211,7 @@ impl TimelineEventView { pub struct IssueConversation { title: Option>, + ln_state: LineNumberState, action_tx: Option>, current: Option, cache_number: Option, @@ -264,13 +266,13 @@ enum MessageKey { } #[derive(Debug, Clone, Default)] -struct MarkdownRender { - lines: Vec>, - links: Vec, +pub(crate) struct MarkdownRender { + pub(crate) lines: Vec>, + pub(crate) links: Vec, } #[derive(Debug, Clone)] -struct RenderedLink { +pub(crate) struct RenderedLink { line: usize, col: usize, label: String, @@ -314,6 +316,7 @@ impl IssueConversation { action_tx: None, current: None, cache_number: None, + ln_state: LineNumberState::default(), cache_comments: Vec::new(), timeline_cache_number: None, cache_timeline: Vec::new(), @@ -358,48 +361,40 @@ impl IssueConversation { return; } self.area = area.main_content; - let title = self.title.clone().unwrap_or_default(); - let wrapped_title = wrap(&title, area.main_content.width.saturating_sub(2) as usize); - let title_para_height = wrapped_title.len() as u16 + 2; - let last_item = wrapped_title.last(); - let last_line = last_item - .as_ref() - .map(|l| { - line![ - l.to_string(), - span!( - " #{}", - self.current.as_ref().map(|s| s.number).unwrap_or_default() - ) - .dim() - ] - }) - .unwrap_or_else(|| Line::from("")); - let wrapped_title_len = wrapped_title.len() as u16; - let title_para = Text::from_iter( - wrapped_title - .into_iter() - .take(wrapped_title_len as usize - 1) - .map(Line::from) - .chain(std::iter::once(last_line)), - ); + let mut title = self.title.clone().unwrap_or_default().to_string(); + title.push_str(&format!( + " #{}", + self.current.as_ref().map(|s| s.number).unwrap_or_default() + )); + let title = title.trim(); + let wrapped_title = wrap(title, area.main_content.width.saturating_sub(2) as usize); + let title_para_height = wrapped_title.len() as u16 + 1; + let title_para = Text::from_iter(wrapped_title); let areas = vertical![==title_para_height, *=1, ==5].split(area.main_content); let title_area = areas[0]; let content_area = areas[1]; let input_area = areas[2]; - let content_split = horizontal![*=1, *=1].split(content_area); + let content_split = horizontal![*=1, *=1] + .spacing(Spacing::Overlap(1)) + .split(content_area); let list_area = content_split[0]; let body_area = content_split[1]; let items = self.build_items(list_area, body_area); let title_widget = widgets::Paragraph::new(title_para) - .block(Block::bordered().border_type(ratatui::widgets::BorderType::Rounded)) + .block( + Block::default() + .padding(Padding::horizontal(1)) + .borders(Borders::BOTTOM) + .merge_borders(ratatui::symbols::merge::MergeStrategy::Exact), + ) .style(Style::default().add_modifier(Modifier::BOLD)); title_widget.render(title_area, buf); - let mut list_block = Block::bordered() - .border_type(ratatui::widgets::BorderType::Rounded) + let mut list_block = Block::default() + .borders(Borders::RIGHT) + .merge_borders(ratatui::symbols::merge::MergeStrategy::Exact) .border_style(get_border_style(&self.list_state)); if !self.is_loading_current() { @@ -449,13 +444,26 @@ impl IssueConversation { match self.textbox_state { InputState::Input => { + let [line_numbers, input_area] = horizontal![==self.input_state.len_lines().checked_ilog10().unwrap_or(0) as u16 + 2, *=1].areas(input_area); + let ln_block = Block::default() + .borders(Borders::TOP) + .merge_borders(ratatui::symbols::merge::MergeStrategy::Exact) + .border_style(get_border_style(&self.input_state)); + let ln = LineNumbers::new() + .with_textarea(&self.input_state) + .block(ln_block) + .style(Style::default().dim()); + ln.render(line_numbers, buf, &mut self.ln_state); + let input_title = if let Some(err) = &self.post_error { format!("Comment (Ctrl+Enter to send) | {err}") } else { "Comment (Ctrl+Enter to send)".to_string() }; - let mut input_block = Block::bordered() - .border_type(ratatui::widgets::BorderType::Rounded) + let mut input_block = Block::default() + .borders(Borders::TOP) + .merge_borders(ratatui::symbols::merge::MergeStrategy::Exact) + .padding(Padding::horizontal(1)) .border_style(get_border_style(&self.input_state)); if !self.posting { input_block = input_block.title(input_title); @@ -470,8 +478,10 @@ impl IssueConversation { render_markdown_lines(&self.input_state.text(), self.markdown_width, 2); let para = Paragraph::new(rendered) .block( - Block::bordered() - .border_type(ratatui::widgets::BorderType::Rounded) + Block::default() + .borders(Borders::TOP) + .merge_borders(ratatui::symbols::merge::MergeStrategy::Exact) + .padding(Padding::horizontal(1)) .border_style(get_border_style(&self.paragraph_state)) .title("Preview"), ) @@ -636,8 +646,9 @@ impl IssueConversation { let body = Paragraph::new(body_lines) .block( - Block::bordered() - .border_type(ratatui::widgets::BorderType::Rounded) + Block::default() + .borders(Borders::LEFT) + .merge_borders(ratatui::symbols::merge::MergeStrategy::Exact) .border_style(get_border_style(&self.body_paragraph_state)) .title(if self.screen == MainScreen::DetailsFullscreen { "Message Body (PageUp/PageDown/Home/End | f/Esc: exit fullscreen)" @@ -2441,7 +2452,7 @@ pub(crate) fn render_markdown_lines(text: &str, width: usize, indent: usize) -> render_markdown(text, width, indent).lines } -fn render_markdown(text: &str, width: usize, indent: usize) -> MarkdownRender { +pub(crate) fn render_markdown(text: &str, width: usize, indent: usize) -> MarkdownRender { let mut renderer = MarkdownRenderer::new(width, indent); let options = Options::ENABLE_GFM | Options::ENABLE_STRIKETHROUGH @@ -2486,11 +2497,56 @@ struct MarkdownRenderer { in_code_block: bool, code_block_lang: Option, code_block_buf: String, - list_prefix: Option, + item_prefix: Option, pending_space: bool, active_link_url: Option, } +#[derive(Debug, Clone)] +struct ListPrefix { + first_line: String, + continuation: String, + first_line_pending: bool, +} + +impl ListPrefix { + fn bullet() -> Self { + Self::new("• ".to_string()) + } + + fn task(checked: bool) -> Self { + let prefix = if checked { "[x] " } else { "[ ] " }; + Self::new(prefix.to_string()) + } + + fn new(first_line: String) -> Self { + let continuation = " ".repeat(display_width(&first_line)); + Self { + first_line, + continuation, + first_line_pending: true, + } + } + + fn current_text(&self) -> &str { + if self.first_line_pending { + &self.first_line + } else { + &self.continuation + } + } + + fn current_width(&self) -> usize { + display_width(self.current_text()) + } + + fn take_for_line(&mut self) -> String { + let prefix = self.current_text().to_string(); + self.first_line_pending = false; + prefix + } +} + #[derive(Clone, Copy)] struct AdmonitionStyle { marker: &'static str, @@ -2553,7 +2609,7 @@ impl MarkdownRenderer { in_code_block: false, code_block_lang: None, code_block_buf: String::new(), - list_prefix: None, + item_prefix: None, pending_space: false, active_link_url: None, } @@ -2593,7 +2649,7 @@ impl MarkdownRenderer { } Tag::Item => { self.flush_line(); - self.list_prefix = Some("• ".to_string()); + self.item_prefix = Some(ListPrefix::bullet()); } _ => {} } @@ -2633,7 +2689,7 @@ impl MarkdownRenderer { } TagEnd::Item => { self.flush_line(); - self.list_prefix = None; + self.item_prefix = None; } TagEnd::Paragraph => { self.flush_line(); @@ -2705,8 +2761,8 @@ impl MarkdownRenderer { fn task_list_marker(&mut self, checked: bool) { self.ensure_admonition_header(); - let marker = if checked { "[x] " } else { "[ ] " }; - self.push_text(marker, self.current_style); + self.item_prefix = Some(ListPrefix::task(checked)); + self.pending_space = false; } fn rule(&mut self) { @@ -2912,9 +2968,10 @@ impl MarkdownRenderer { .unwrap_or_else(|| Style::new().fg(Color::DarkGray)); self.current_line.push(Span::styled("│ ", border_style)); } - if let Some(prefix) = &self.list_prefix { - self.current_width += display_width(prefix); - self.current_line.push(Span::raw(prefix.clone())); + if let Some(prefix) = self.item_prefix.as_mut() { + let prefix = prefix.take_for_line(); + self.current_width += display_width(&prefix); + self.current_line.push(Span::raw(prefix)); } } @@ -2923,8 +2980,8 @@ impl MarkdownRenderer { if self.in_block_quote { width += 2; } - if let Some(prefix) = &self.list_prefix { - width += display_width(prefix); + if let Some(prefix) = &self.item_prefix { + width += prefix.current_width(); } width } @@ -3079,6 +3136,19 @@ mod tests { .collect() } + fn all_line_text(rendered: &super::MarkdownRender) -> Vec { + rendered + .lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + }) + .collect() + } + #[test] fn extracts_link_segments_with_urls() { let rendered = render_markdown("Go to [ratatui docs](https://github.com/ratatui/).", 80, 0); @@ -3111,4 +3181,35 @@ mod tests { .all(|link| !link.label.starts_with(' ') && !link.label.ends_with(' ')) ); } + + #[test] + fn renders_unchecked_checklist_without_bullet_prefix() { + let rendered = render_markdown("- [ ] todo", 80, 0); + + assert_eq!(all_line_text(&rendered), vec!["[ ] todo"]); + } + + #[test] + fn renders_checked_checklist_without_bullet_prefix() { + let rendered = render_markdown("- [x] done", 80, 0); + + assert_eq!(all_line_text(&rendered), vec!["[x] done"]); + } + + #[test] + fn wraps_checklist_items_with_aligned_continuation() { + let rendered = render_markdown("- [ ] hello world", 10, 0); + + assert_eq!(all_line_text(&rendered), vec!["[ ] hello", " world"]); + } + + #[test] + fn keeps_bullets_for_non_task_list_items() { + let rendered = render_markdown("- bullet\n- [x] done\n- [ ] todo", 80, 0); + + assert_eq!( + all_line_text(&rendered), + vec!["• bullet", "[x] done", "[ ] todo"] + ); + } } diff --git a/src/ui/components/issue_convo_preview.rs b/src/ui/components/issue_convo_preview.rs new file mode 100644 index 0000000..0553970 --- /dev/null +++ b/src/ui/components/issue_convo_preview.rs @@ -0,0 +1,526 @@ +use async_trait::async_trait; +use crossterm::event; +use rat_widget::{ + event::{HandleEvent, Regular, ct_event}, + focus::{FocusBuilder, FocusFlag, HasFocus, Navigation}, + paragraph::ParagraphState, +}; +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + text::Span, + widgets::{ + self, Block, Borders, List as TuiList, ListItem, ListState as TuiListState, Padding, + StatefulWidget, Widget, + }, +}; +use std::sync::{Arc, RwLock}; +use textwrap::wrap; + +use crate::{ + errors::AppError, + ui::{ + Action, + components::{ + Component, + help::HelpElementKind, + issue_conversation::render_markdown, + issue_detail::IssuePreviewSeed, + issue_list::{MainScreen, build_issue_list_item, build_issue_list_lines}, + }, + issue_data::{IssueId, UiIssuePool}, + layout::Layout, + utils::get_border_style, + }, +}; + +pub const HELP: &[HelpElementKind] = &[ + crate::help_text!("Issue Conversation Preview Help"), + crate::help_text!("* marks the issue currently open in details"), + crate::help_keybind!("Up/Down", "select nearby issue"), + crate::help_keybind!("Enter", "open selected issue"), + crate::help_keybind!("Tab", "move focus forward"), + crate::help_keybind!("Shift+Tab / Esc", "move focus back"), +]; + +pub struct IssueConvoPreview { + action_tx: Option>, + issue_pool: Arc>, + body: Option>, + issue_ids: Vec, + open_number: Option, + selected_number: Option, + screen: MainScreen, + area: Rect, + paragraph_state: ParagraphState, + list_state: TuiListState, + index: usize, + focus: FocusFlag, +} + +impl IssueConvoPreview { + pub fn new(issue_pool: Arc>) -> Self { + Self { + action_tx: None, + issue_pool, + body: None, + issue_ids: Vec::new(), + open_number: None, + selected_number: None, + screen: MainScreen::List, + area: Rect::default(), + paragraph_state: ParagraphState::default(), + list_state: TuiListState::default(), + index: 0, + focus: FocusFlag::new().with_name("issue_convo_preview"), + } + } + + pub fn render(&mut self, area: Layout, buf: &mut Buffer) { + self.area = area.mini_convo_preview; + match self.screen { + MainScreen::List => self.render_body_preview(area.mini_convo_preview, buf), + MainScreen::Details => self.render_issue_list_preview(area.mini_convo_preview, buf), + MainScreen::CreateIssue => { + let para = widgets::Paragraph::new("No preview available in fullscreen mode") + .block( + Block::default() + .borders(Borders::LEFT | Borders::BOTTOM) + .title(format!("[{}] Issue Conversation", self.index)) + .merge_borders(ratatui::symbols::merge::MergeStrategy::Exact) + .border_style(get_border_style(&self.paragraph_state)), + ); + para.render(area.mini_convo_preview, buf); + } + MainScreen::DetailsFullscreen => {} + } + } + + fn render_body_preview(&mut self, area: Rect, buf: &mut Buffer) { + let block_template = Block::default() + .borders(Borders::LEFT | Borders::BOTTOM) + .border_style(get_border_style(&self.paragraph_state)); + + let Some(ref body) = self.body else { + let para = + ratatui::widgets::Paragraph::new("Select an issue to preview the conversation") + .block( + block_template + .title(format!("[{}] Issue Conversation", self.index)) + .merge_borders(ratatui::symbols::merge::MergeStrategy::Exact), + ); + para.render(area, buf); + return; + }; + let body_str = wrap(body, area.width.saturating_sub(2) as usize).join("\n"); + let rendered = render_markdown(&body_str, area.width.saturating_sub(2).into(), 2).lines; + let para = rat_widget::paragraph::Paragraph::new(rendered).block( + Block::default() + .borders(Borders::LEFT | Borders::TOP | Borders::BOTTOM) + .title(format!("[{}] Issue Body", self.index)) + .merge_borders(ratatui::symbols::merge::MergeStrategy::Exact) + .border_style(get_border_style(&self.paragraph_state)), + ); + para.render(area, buf, &mut self.paragraph_state); + } + + fn render_issue_list_preview(&mut self, area: Rect, buf: &mut Buffer) { + let block = Block::default() + .borders(Borders::LEFT | Borders::BOTTOM) + .padding(Padding::horizontal(1)) + .title(format!("[{}] Nearby Issues", self.index)) + .merge_borders(ratatui::symbols::merge::MergeStrategy::Exact) + .border_style(get_border_style(&self.paragraph_state)); + + if self.issue_ids.is_empty() { + let para = ratatui::widgets::Paragraph::new("No nearby issues available.").block(block); + para.render(area, buf); + return; + } + + let items = { + let pool = self.issue_pool.read().expect("issue pool lock poisoned"); + self.issue_ids + .iter() + .map(|issue_id| { + let issue = pool.get_issue(*issue_id); + if Some(issue.number) == self.open_number { + let mut lines = build_issue_list_lines(issue, &pool, false, false); + if let Some(first_line) = lines.first_mut() { + first_line.spans.insert( + 0, + Span::styled( + "* ", + Style::new().fg(Color::Green).add_modifier(Modifier::BOLD), + ), + ); + } + ListItem::new(lines) + } else { + build_issue_list_item(issue, &pool, false, false) + } + }) + .collect::>() + }; + + let selected = self.selected_number.and_then(|number| { + let pool = self.issue_pool.read().expect("issue pool lock poisoned"); + self.issue_ids + .iter() + .position(|issue_id| pool.get_issue(*issue_id).number == number) + }); + self.list_state.select(selected); + + let list = TuiList::new(items) + .block(block) + .highlight_style(Style::new().add_modifier(Modifier::BOLD | Modifier::REVERSED)); + StatefulWidget::render(list, area, buf, &mut self.list_state); + } + + fn selected_issue_id(&self) -> Option { + let selected = self.list_state.selected()?; + self.issue_ids.get(selected).copied() + } + + fn sync_selected_issue(&mut self) { + let selected = self.selected_number.and_then(|number| { + let pool = self.issue_pool.read().expect("issue pool lock poisoned"); + self.issue_ids + .iter() + .position(|issue_id| pool.get_issue(*issue_id).number == number) + }); + self.list_state.select(selected); + } + + async fn open_selected_issue(&mut self) -> Result<(), AppError> { + let Some(issue_id) = self.selected_issue_id() else { + return Ok(()); + }; + let Some(action_tx) = self.action_tx.clone() else { + return Ok(()); + }; + + let (number, labels, preview_seed, conversation_seed) = { + let pool = self.issue_pool.read().expect("issue pool lock poisoned"); + let issue = pool.get_issue(issue_id); + ( + issue.number, + issue.labels.clone(), + IssuePreviewSeed::from_ui_issue(issue, &pool), + crate::ui::components::issue_conversation::IssueConversationSeed::from_ui_issue( + issue, &pool, + ), + ) + }; + + self.open_number = Some(number); + self.selected_number = Some(number); + self.sync_selected_issue(); + action_tx + .send(Action::SelectedIssue { number, labels }) + .await?; + action_tx + .send(Action::SelectedIssuePreview { seed: preview_seed }) + .await?; + action_tx + .send(Action::IssueListPreviewUpdated { + issue_ids: self.issue_ids.clone(), + selected_number: number, + }) + .await?; + action_tx + .send(Action::EnterIssueDetails { + seed: conversation_seed, + }) + .await?; + Ok(()) + } +} + +#[async_trait(?Send)] +impl Component for IssueConvoPreview { + fn render(&mut self, area: Layout, buf: &mut Buffer) { + self.render(area, buf); + } + + fn register_action_tx(&mut self, action_tx: tokio::sync::mpsc::Sender) { + self.action_tx = Some(action_tx); + } + + async fn handle_event(&mut self, event: Action) -> Result<(), AppError> { + match event { + Action::AppEvent(ref event) => { + if self.screen == MainScreen::List { + self.paragraph_state.handle(event, Regular); + } else if self.screen == MainScreen::Details && self.paragraph_state.is_focused() { + match event { + ct_event!(keycode press Up) => { + self.list_state.select_previous(); + self.selected_number = self.selected_issue_id().map(|issue_id| { + let pool = + self.issue_pool.read().expect("issue pool lock poisoned"); + pool.get_issue(issue_id).number + }); + } + ct_event!(keycode press Down) => { + self.list_state.select_next(); + self.selected_number = self.selected_issue_id().map(|issue_id| { + let pool = + self.issue_pool.read().expect("issue pool lock poisoned"); + pool.get_issue(issue_id).number + }); + } + ct_event!(keycode press Enter) => { + self.open_selected_issue().await?; + } + ct_event!(keycode press Tab) => { + if let Some(action_tx) = self.action_tx.as_ref() { + action_tx.send(Action::ForceFocusChange).await?; + } + } + ct_event!(keycode press SHIFT-BackTab) | ct_event!(keycode press Esc) => { + if let Some(action_tx) = self.action_tx.as_ref() { + action_tx.send(Action::ForceFocusChangeRev).await?; + } + } + _ => {} + } + } + } + Action::ChangeIssueBodyPreview(body) => { + self.body = Some(body); + } + Action::IssueListPreviewUpdated { + issue_ids, + selected_number, + } => { + self.issue_ids = issue_ids; + self.open_number = Some(selected_number); + self.selected_number = Some(selected_number); + self.sync_selected_issue(); + } + Action::ChangeIssueScreen(screen) => { + self.screen = screen; + if screen != MainScreen::Details { + self.paragraph_state.focus.set(false); + } + } + _ => {} + } + Ok(()) + } + + fn should_render(&self) -> bool { + true + } + + fn is_animating(&self) -> bool { + false + } + + fn set_index(&mut self, index: usize) { + self.index = index; + } + + fn set_global_help(&self) { + if let Some(action_tx) = &self.action_tx { + let _ = action_tx.try_send(Action::SetHelp(HELP)); + } + } + + fn capture_focus_event(&self, event: &event::Event) -> bool { + if self.screen != MainScreen::Details || !self.paragraph_state.is_focused() { + return false; + } + + match event { + event::Event::Key(key) => matches!( + key.code, + event::KeyCode::Up + | event::KeyCode::Down + | event::KeyCode::Enter + | event::KeyCode::Tab + | event::KeyCode::BackTab + | event::KeyCode::Esc + ), + _ => false, + } + } +} + +impl HasFocus for IssueConvoPreview { + fn build(&self, builder: &mut FocusBuilder) { + let tag = builder.start(self); + builder.widget(&self.paragraph_state); + builder.end(tag); + } + + fn focus(&self) -> FocusFlag { + self.focus.clone() + } + + fn area(&self) -> Rect { + self.area + } + + fn navigable(&self) -> Navigation { + if self.screen == MainScreen::Details { + Navigation::Regular + } else { + Navigation::None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::testing::{DummyDataConfig, dummy_ui_data_with}; + use octocrab::models::Label; + use ratatui::{buffer::Buffer, layout::Rect}; + use tokio::sync::mpsc; + + fn buffer_text(buf: &Buffer) -> String { + let area = buf.area; + (area.top()..area.bottom()) + .map(|y| { + (area.left()..area.right()) + .map(|x| buf[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n") + } + + #[test] + fn renders_body_preview_in_list_mode() { + let data = dummy_ui_data_with(DummyDataConfig { + issue_count: 3, + ..DummyDataConfig::default() + }); + let pool = Arc::new(RwLock::new(data.pool)); + let mut preview = IssueConvoPreview::new(pool); + preview.body = Some(Arc::::from("hello from preview body")); + + let mut buf = Buffer::empty(Rect::new(0, 0, 80, 24)); + preview.render(Layout::fullscreen(Rect::new(0, 0, 80, 24)), &mut buf); + + let text = buffer_text(&buf); + assert!(text.contains("Issue Body")); + assert!(text.contains("hello from preview body")); + } + + #[test] + fn renders_nearby_issues_in_details_mode() { + let data = dummy_ui_data_with(DummyDataConfig { + issue_count: 4, + ..DummyDataConfig::default() + }); + let selected_id = data.issue_ids[1]; + let open_number = data.issue_numbers[1]; + let selected_number = data.issue_numbers[2]; + let pool = Arc::new(RwLock::new(data.pool)); + let mut preview = IssueConvoPreview::new(pool); + preview.screen = MainScreen::Details; + preview.issue_ids = data.issue_ids.clone(); + preview.open_number = Some(open_number); + preview.selected_number = Some(selected_number); + preview.sync_selected_issue(); + + let mut buf = Buffer::empty(Rect::new(0, 0, 80, 24)); + preview.render(Layout::fullscreen(Rect::new(0, 0, 80, 24)), &mut buf); + + let text = buffer_text(&buf); + assert!(text.contains("Nearby Issues")); + assert!(text.contains(&format!("#{open_number}"))); + assert!(text.contains(&format!("#{selected_number}"))); + + let pool = preview.issue_pool.read().expect("issue pool lock poisoned"); + let open_title = pool.resolve_str(pool.get_issue(selected_id).title); + let selected_title = pool.resolve_str(pool.get_issue(data.issue_ids[2]).title); + assert!(text.contains(&format!("* {open_title}"))); + assert!(!text.contains(&format!("* {selected_title}"))); + } + + #[test] + fn renders_nothing_in_fullscreen_mode() { + let data = dummy_ui_data_with(DummyDataConfig::default()); + let pool = Arc::new(RwLock::new(data.pool)); + let mut preview = IssueConvoPreview::new(pool); + preview.screen = MainScreen::DetailsFullscreen; + + let mut buf = Buffer::empty(Rect::new(0, 0, 80, 24)); + preview.render(Layout::fullscreen(Rect::new(0, 0, 80, 24)), &mut buf); + + let text = buffer_text(&buf); + assert!(text.trim().is_empty()); + } + + #[tokio::test] + async fn opens_selected_issue_from_preview() { + let data = dummy_ui_data_with(DummyDataConfig { + issue_count: 4, + ..DummyDataConfig::default() + }); + let selected_id = data.issue_ids[1]; + let selected_number = data.issue_numbers[1]; + let expected_author = data + .preview_seeds + .get(&selected_id) + .expect("preview seed should exist") + .author + .clone(); + let expected_labels: Vec