From 7c0c15e921a65878cdb3420ea66b8e90b8255eb9 Mon Sep 17 00:00:00 2001 From: JayanAXHF Date: Sat, 14 Mar 2026 17:57:42 +0530 Subject: [PATCH 1/3] feat(preview pane): Add a small issue list preview when in issue conversation fix: render placeholder in create issue mode for convo preview --- src/ui/components/issue_convo_preview.rs | 322 +++++++++++++++++++++-- src/ui/components/issue_create.rs | 6 + src/ui/components/issue_list.rs | 200 +++++++++++--- src/ui/mod.rs | 6 +- 4 files changed, 471 insertions(+), 63 deletions(-) diff --git a/src/ui/components/issue_convo_preview.rs b/src/ui/components/issue_convo_preview.rs index 2aa7b73..8fd95fc 100644 --- a/src/ui/components/issue_convo_preview.rs +++ b/src/ui/components/issue_convo_preview.rs @@ -1,73 +1,116 @@ use async_trait::async_trait; +use crossterm::event; use rat_widget::{ - event::{HandleEvent, Regular}, - focus::{FocusBuilder, FocusFlag, HasFocus}, + event::{HandleEvent, Regular, ct_event}, + focus::{FocusBuilder, FocusFlag, HasFocus, Navigation}, paragraph::ParagraphState, }; use ratatui::{ buffer::Buffer, layout::Rect, - widgets::{Block, Borders, StatefulWidget, Widget}, + 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}, + 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 Help"), - crate::help_keybind!("Up/Down", "select issue body/comment entry"), - crate::help_keybind!("PageUp/PageDown/Home/End", "scroll message body pane"), - crate::help_keybind!("t", "toggle timeline events"), - crate::help_keybind!("f", "toggle fullscreen body view"), - crate::help_keybind!("C", "close selected issue"), - crate::help_keybind!("l", "copy link to selected message"), - crate::help_keybind!("Enter (popup)", "confirm close reason"), - crate::help_keybind!("Ctrl+P", "toggle comment input/preview"), - crate::help_keybind!("e", "edit selected comment in external editor"), - crate::help_keybind!("r", "add reaction to selected comment"), - crate::help_keybind!("R", "remove reaction from selected comment"), - crate::help_keybind!("Ctrl+Enter / Alt+Enter", "send comment"), - crate::help_keybind!("Esc", "exit fullscreen / return to issue list"), + 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"), ]; -#[derive(Default)] 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() -> Self { - Self::default() + 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)); - self.area = area.mini_convo_preview; 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)) + .title(format!("[{}] Issue Conversation", self.index)) .merge_borders(ratatui::symbols::merge::MergeStrategy::Exact), ); - para.render(area.mini_convo_preview, buf); + para.render(area, buf); return; }; let rendered = render_markdown(body, area.width.saturating_sub(2).into(), 2).lines; @@ -201,11 +244,61 @@ impl Component for IssueConvoPreview { async fn handle_event(&mut self, event: Action) -> Result<(), AppError> { match event { Action::AppEvent(ref event) => { - self.paragraph_state.handle(event, Regular); + 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(()) @@ -228,6 +321,25 @@ impl Component for IssueConvoPreview { 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 { @@ -244,4 +356,164 @@ impl HasFocus for IssueConvoPreview { 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