From ef6f906e41ff2783386d05e8fd6eb5b9c3cfce2f Mon Sep 17 00:00:00 2001 From: JayanAXHF Date: Wed, 11 Mar 2026 18:20:26 +0530 Subject: [PATCH 1/9] feat(style): Revamp the entire UI to have less borders and distractions 1. Removed a lot of unnecessary borders around elements. 2. Added line numbers for the input in issue conversation 3. Added a `BodyPreview` component that shows a preview of the issue body in the TUI list widget. --- src/ui/components/issue_conversation.rs | 89 +++++++------ src/ui/components/issue_convo_preview.rs | 161 +++++++++++++++++++++++ src/ui/components/issue_detail.rs | 19 +-- src/ui/components/issue_list.rs | 17 ++- src/ui/components/label_list.rs | 15 ++- src/ui/components/mod.rs | 1 + src/ui/components/status_bar.rs | 6 +- src/ui/layout.rs | 11 +- src/ui/mod.rs | 4 + src/ui/testing.rs | 10 +- src/ui/utils.rs | 2 +- 11 files changed, 261 insertions(+), 74 deletions(-) create mode 100644 src/ui/components/issue_convo_preview.rs diff --git a/src/ui/components/issue_conversation.rs b/src/ui/components/issue_conversation.rs index c4da2b5..75901bf 100644 --- a/src/ui/components/issue_conversation.rs +++ b/src/ui/components/issue_conversation.rs @@ -12,16 +12,17 @@ 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, + layout::{Rect, Spacing}, style::{Color, Modifier, Style, Stylize}, 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 std::{ @@ -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 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 + 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 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); @@ -636,8 +644,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 +2450,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 diff --git a/src/ui/components/issue_convo_preview.rs b/src/ui/components/issue_convo_preview.rs new file mode 100644 index 0000000..dcaf713 --- /dev/null +++ b/src/ui/components/issue_convo_preview.rs @@ -0,0 +1,161 @@ +use async_trait::async_trait; +use rat_widget::{ + event::{HandleEvent, Regular}, + focus::{FocusBuilder, FocusFlag, HasFocus}, + paragraph::ParagraphState, +}; +use ratatui::{ + buffer::Buffer, + layout::Rect, + widgets::{Block, Borders, 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_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"), +]; + +pub struct IssueConvoPreview { + action_tx: Option>, + body: Option>, + area: Rect, + current: Option, + paragraph_state: ParagraphState, + index: usize, + focus: FocusFlag, +} + +impl IssueConvoPreview { + pub fn new() -> Self { + Self { + action_tx: None, + current: None, + body: None, + index: 0, + focus: FocusFlag::default(), + area: Rect::default(), + paragraph_state: ParagraphState::default(), + } + } + + pub fn render(&mut self, area: Layout, 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)) + .merge_borders(ratatui::symbols::merge::MergeStrategy::Exact), + ); + para.render(area.mini_convo_preview, buf); + return; + }; + let body_str = wrap( + &body, + area.mini_convo_preview.width.saturating_sub(2) as usize, + ) + .join("\n"); + let rendered = render_markdown( + &body_str, + area.mini_convo_preview.width.saturating_sub(2).into(), + 2, + ) + .lines; + let para = rat_widget::paragraph::Paragraph::new(rendered).block( + Block::default() + .borders(Borders::LEFT | 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.mini_convo_preview, buf, &mut self.paragraph_state); + } +} + +#[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) => { + self.paragraph_state.handle(event, Regular); + } + Action::ChangeIssueBodyPreview(body) => { + self.body = Some(body); + } + _ => {} + } + 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)); + } + } +} + +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 + } +} diff --git a/src/ui/components/issue_detail.rs b/src/ui/components/issue_detail.rs index 255126c..ae18dc0 100644 --- a/src/ui/components/issue_detail.rs +++ b/src/ui/components/issue_detail.rs @@ -7,8 +7,9 @@ use ratatui::{ layout::{Constraint, Direction, Layout as RtLayout, Rect}, prelude::Widget, style::Style, + symbols::merge::MergeStrategy, text::{Line, Span, Text}, - widgets::{Block, Paragraph, Wrap}, + widgets::{Block, Borders, Paragraph, Wrap}, }; use ratatui_macros::line; @@ -115,9 +116,10 @@ impl IssuePreview { pub fn render(&mut self, area: Layout, buf: &mut Buffer) { self.area = area.issue_preview; - let block = Block::bordered() - .border_type(ratatui::widgets::BorderType::Rounded) - .title("Issue Info"); + let block = Block::default() + .borders(Borders::LEFT | Borders::TOP) + .border_style(Style::new().dim()) + .merge_borders(MergeStrategy::Exact); let inner = block.inner(area.issue_preview); block.render(area.issue_preview, buf); @@ -169,15 +171,6 @@ impl IssuePreview { _ => Style::new().cyan(), }; - let kind = if seed.is_pull_request { - "Pull Request" - } else { - "Issue" - }; - lines.push(Line::from(vec![ - Span::styled("Type: ", label_style), - Span::styled(kind, Style::new().cyan()), - ])); lines.push(Line::from(vec![ Span::styled("State: ", label_style), Span::styled(format!("{:?}", seed.state), state_style), diff --git a/src/ui/components/issue_list.rs b/src/ui/components/issue_list.rs index 366d0cc..2b1e27f 100644 --- a/src/ui/components/issue_list.rs +++ b/src/ui/components/issue_list.rs @@ -686,10 +686,7 @@ impl<'a> IssueList<'a> { area.main_content = split[0]; assign_input_area = split[1]; } - let mut block = Block::bordered() - .border_type(ratatui::widgets::BorderType::Rounded) - .border_style(get_border_style(&self.list_state)) - .padding(Padding::horizontal(3)); + let mut block = Block::default().padding(Padding::horizontal(3)); if self.state != LoadingState::Loading { let mut title = format!("[{}] Issues", self.index); if let Some(err) = &self.close_error { @@ -1166,6 +1163,18 @@ impl Component for IssueList<'_> { let (issue_number, labels, preview_seed) = { let pool = self.issue_pool.read().expect("issue pool lock poisoned"); let issue = pool.get_issue(self.issues[selected].0); + if let Some(body_id) = issue.body { + let body = pool.resolve_str(body_id); + self.action_tx + .as_ref() + .ok_or_else(|| { + AppError::Other(anyhow!( + "issue list action channel unavailable" + )) + })? + .send(crate::ui::Action::ChangeIssueBodyPreview(body.into())) + .await?; + } ( issue.number, issue.labels.clone(), diff --git a/src/ui/components/label_list.rs b/src/ui/components/label_list.rs index 4110162..480655b 100644 --- a/src/ui/components/label_list.rs +++ b/src/ui/components/label_list.rs @@ -19,7 +19,8 @@ use ratatui::{ buffer::Buffer, layout::{Constraint, Direction, Layout as TuiLayout, Rect}, style::{Color, Style, Stylize}, - widgets::{Block, Clear, ListItem, Paragraph, StatefulWidget, Widget}, + symbols::merge::MergeStrategy, + widgets::{Block, Borders, Clear, ListItem, Paragraph, StatefulWidget, Widget}, }; use ratatui_macros::{line, span}; use regex::RegexBuilder; @@ -204,7 +205,11 @@ impl LabelList { if self.needs_footer() { let areas = TuiLayout::default() .direction(Direction::Vertical) - .constraints([Constraint::Min(1), Constraint::Length(3)]) + .constraints([ + Constraint::Min(1), + Constraint::Length(3), + Constraint::Length(1), + ]) .split(area.label_list); list_area = areas[0]; footer_area = Some(areas[1]); @@ -219,8 +224,9 @@ impl LabelList { } else { format!("[{}] Labels (a:add d:remove)", self.index) }; - let block = Block::bordered() - .border_type(ratatui::widgets::BorderType::Rounded) + let block = Block::default() + .borders(Borders::LEFT | Borders::BOTTOM) + .merge_borders(MergeStrategy::Exact) .title(title) .border_style(get_border_style(&self.state)); let list = rat_widget::list::List::::new( @@ -1262,4 +1268,3 @@ impl HasFocus for LabelList { self.state.focus() } } - diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 9d38540..ccda5d8 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -8,6 +8,7 @@ use ratatui::crossterm::event::Event; pub mod help; pub mod issue_conversation; +pub mod issue_convo_preview; pub mod issue_create; pub mod issue_detail; pub mod issue_list; diff --git a/src/ui/components/status_bar.rs b/src/ui/components/status_bar.rs index cef55b3..ed06849 100644 --- a/src/ui/components/status_bar.rs +++ b/src/ui/components/status_bar.rs @@ -5,9 +5,9 @@ use ratatui::widgets::Widget; use ratatui_macros::{line, span}; use std::sync::atomic::Ordering; -use crate::ui::components::issue_list::LOADED_ISSUE_COUNT; use crate::ui::components::DumbComponent; -use crate::ui::{layout::Layout, AppState}; +use crate::ui::components::issue_list::LOADED_ISSUE_COUNT; +use crate::ui::{AppState, layout::Layout}; pub struct StatusBar { repo_label: String, @@ -41,7 +41,7 @@ impl StatusBar { .end(span!(count_text).style(Style::new().black().on_blue()), "") .end( line![ - span!("q///").magenta(), " ", span!(" QUIT ").black().on_magenta().bold() ], diff --git a/src/ui/layout.rs b/src/ui/layout.rs index ea1a861..083d38d 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -1,9 +1,10 @@ -use ratatui::layout::Rect; +use ratatui::layout::{Rect, Spacing}; use ratatui_macros::{horizontal, vertical}; #[derive(Debug, Clone, Copy)] pub struct Layout { pub status_bar: Rect, + pub mini_convo_preview: Rect, pub main_content: Rect, pub label_list: Rect, pub text_search: Rect, @@ -16,12 +17,15 @@ pub struct Layout { impl Layout { pub fn new(area: Rect) -> Self { let [title_bar, main, status_bar] = vertical![==1, *=1, ==1].areas(area); - let [left, right] = horizontal![==70%, *=1].areas(main); - let [label_list, issue_preview] = vertical![*=1, *=1].areas(right); + let [left, right] = horizontal![==66%, *=1].areas(main); + let [mini_convo_preview, label_list, issue_preview] = vertical![==60%, *=1, *=1] + .spacing(Spacing::Overlap(1)) + .areas(right); let [text_search, bottom_search, main_content] = vertical![==3, ==3, *=1].areas(left); let [label_search, status_dropdown] = horizontal![*=1, ==30%].areas(bottom_search); Self { status_dropdown, + mini_convo_preview, title_bar, status_bar, main_content, @@ -34,6 +38,7 @@ impl Layout { pub fn fullscreen(area: Rect) -> Self { Self { + mini_convo_preview: area, status_bar: area, main_content: area, label_list: area, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 73ccdfd..6a7eabe 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -18,6 +18,7 @@ use crate::{ Component, DumbComponent, help::HelpElementKind, issue_conversation::IssueConversation, + issue_convo_preview::IssueConvoPreview, issue_create::IssueCreate, issue_detail::IssuePreview, issue_list::{IssueList, MainScreen}, @@ -190,6 +191,7 @@ impl App { let issue_pool = Arc::new(RwLock::new(UiIssuePool::default())); let mut issue_conversation = IssueConversation::new(state.clone(), issue_pool.clone()); let mut issue_create = IssueCreate::new(state.clone(), issue_pool.clone()); + let mut issue_convo_preview = IssueConvoPreview::new(); let bookmarks = Arc::new(RwLock::new(read_bookmarks())); let issue_handler = GITHUB_CLIENT .get() @@ -211,6 +213,7 @@ impl App { 3 -> issue_conversation, 5 -> issue_create, 4 -> label_list, + 6 -> issue_convo_preview, 1 -> text_search, // this needs to be the last one )?; let effects_manager = EffectManager::default(); @@ -666,6 +669,7 @@ pub enum Action { EnterIssueDetails { seed: IssueConversationSeed, }, + ChangeIssueBodyPreview(Arc), IssueCommentsLoaded { number: u64, comments: Vec, diff --git a/src/ui/testing.rs b/src/ui/testing.rs index 73f025f..74260dd 100644 --- a/src/ui/testing.rs +++ b/src/ui/testing.rs @@ -214,7 +214,7 @@ fn make_issue( .choose(rng) .map(|author| author.author_id) .expect("author fixture list should not be empty"); - let state = if idx % 5 == 0 { + let state = if idx.is_multiple_of(5) { IssueState::Closed } else { IssueState::Open @@ -223,7 +223,7 @@ fn make_issue( "{} #{issue_number}", Sentence(3..6).fake_with_rng::(rng) ); - let shared_fragment = if idx % 2 == 0 { + let shared_fragment = if idx.is_multiple_of(2) { issue_body_fixture(1) } else { markdown_fixture(1) @@ -239,13 +239,13 @@ fn make_issue( let created_at_short = format_timestamp(created_ts, false); let created_at_full = format_timestamp(created_ts, true); let updated_at_short = format_timestamp(created_ts + 1_800, false); - let milestone = (idx % 3 == 0).then(|| { + let milestone = (idx.is_multiple_of(3)).then(|| { let milestone = milestones .choose(rng) .expect("milestone fixture list should not be empty"); pool.intern_str(milestone) }); - let assignee_count = 1 + (idx % authors.len().min(3).max(1)); + let assignee_count = 1 + (idx % authors.len().clamp(1, 3)); let assignees = authors .iter() .cycle() @@ -253,7 +253,7 @@ fn make_issue( .take(assignee_count) .map(|author| author.author_id) .collect(); - let is_pull_request = idx % 4 == 0; + let is_pull_request = idx.is_multiple_of(4); let pull_request_url = if is_pull_request { let url = format!("https://github.com/example/repo/pull/{issue_number}"); Some(pool.intern_str(&url)) diff --git a/src/ui/utils.rs b/src/ui/utils.rs index 194b6b3..7099784 100644 --- a/src/ui/utils.rs +++ b/src/ui/utils.rs @@ -12,7 +12,7 @@ pub fn get_loader_area(area: Rect) -> Rect { #[inline(always)] pub fn get_border_style(state: &impl HasFocus) -> Style { - let default_border_style = Style::default(); + let default_border_style = Style::default().dim(); let focused_border_style = Style::default().yellow(); if state.is_focused() { focused_border_style From b6ed999719a759956ac21d79ec4fe45834e055ff Mon Sep 17 00:00:00 2001 From: JayanAXHF Date: Wed, 11 Mar 2026 18:25:48 +0530 Subject: [PATCH 2/9] test(ui): update tests --- ...ue_preview__issue_preview_closed_issue.snap | 18 +++++++++--------- ..._preview__issue_preview_many_assignees.snap | 18 +++++++++--------- ...ue_preview__issue_preview_no_selection.snap | 18 +++++++++--------- ...ssue_preview__issue_preview_open_issue.snap | 18 +++++++++--------- ...ue_preview__issue_preview_pull_request.snap | 18 +++++++++--------- .../status_bar__status_bar_with_count.snap | 2 +- .../text_search__text_search_both_inputs.snap | 12 ++++++------ .../text_search__text_search_label_input.snap | 12 ++++++------ .../text_search__text_search_loaded_state.snap | 12 ++++++------ .../text_search__text_search_with_input.snap | 12 ++++++------ 10 files changed, 70 insertions(+), 70 deletions(-) diff --git a/tests/snapshots/issue_preview__issue_preview_closed_issue.snap b/tests/snapshots/issue_preview__issue_preview_closed_issue.snap index 9feb229..f929dec 100644 --- a/tests/snapshots/issue_preview__issue_preview_closed_issue.snap +++ b/tests/snapshots/issue_preview__issue_preview_closed_issue.snap @@ -12,12 +12,12 @@ expression: result - ╭Issue Info╮ - │Type: │ - │Issue │ - │State: │ - │Closed │ - │Author: │ - │janedoe │ - │Created: │ - ╰──────────╯ + + + + + ┌───────────── + │State: Closed + │Author: + │janedoe + │Created: diff --git a/tests/snapshots/issue_preview__issue_preview_many_assignees.snap b/tests/snapshots/issue_preview__issue_preview_many_assignees.snap index 431fa51..14c7a97 100644 --- a/tests/snapshots/issue_preview__issue_preview_many_assignees.snap +++ b/tests/snapshots/issue_preview__issue_preview_many_assignees.snap @@ -12,12 +12,12 @@ expression: result - ╭Issue Info╮ - │Type: │ - │Issue │ - │State: │ - │Open │ - │Author: │ - │teamlead │ - │Created: │ - ╰──────────╯ + + + + + ┌───────────── + │State: Open + │Author: + │teamlead + │Created: diff --git a/tests/snapshots/issue_preview__issue_preview_no_selection.snap b/tests/snapshots/issue_preview__issue_preview_no_selection.snap index 9f41a91..12cf70d 100644 --- a/tests/snapshots/issue_preview__issue_preview_no_selection.snap +++ b/tests/snapshots/issue_preview__issue_preview_no_selection.snap @@ -12,12 +12,12 @@ expression: result - ╭Issue Info╮ - │Select an │ - │issue to │ - │see │ - │details. │ - │ │ - │ │ - │ │ - ╰──────────╯ + + + + + ┌───────────── + │Select an + │issue to see + │details. + │ diff --git a/tests/snapshots/issue_preview__issue_preview_open_issue.snap b/tests/snapshots/issue_preview__issue_preview_open_issue.snap index 189ff80..1460079 100644 --- a/tests/snapshots/issue_preview__issue_preview_open_issue.snap +++ b/tests/snapshots/issue_preview__issue_preview_open_issue.snap @@ -12,12 +12,12 @@ expression: result - ╭Issue Info╮ - │Type: │ - │Issue │ - │State: │ - │Open │ - │Author: │ - │johndoe │ - │Created: │ - ╰──────────╯ + + + + + ┌───────────── + │State: Open + │Author: + │johndoe + │Created: diff --git a/tests/snapshots/issue_preview__issue_preview_pull_request.snap b/tests/snapshots/issue_preview__issue_preview_pull_request.snap index 58625a1..732cbec 100644 --- a/tests/snapshots/issue_preview__issue_preview_pull_request.snap +++ b/tests/snapshots/issue_preview__issue_preview_pull_request.snap @@ -12,12 +12,12 @@ expression: result - ╭Issue Info╮ - │Type: Pull│ - │Request │ - │State: │ - │Open │ - │Author: │ - │devuser │ - │]8;;https://github.com/owner/repo/pull/456\Open #4...]8;;\ │ - ╰──────────╯ + + + + + ┌───────────── + │State: Open + │Author: + │devuser + │]8;;https://github.com/owner/repo/pull/456\Open #456 ...]8;;\ diff --git a/tests/snapshots/status_bar__status_bar_with_count.snap b/tests/snapshots/status_bar__status_bar_with_count.snap index f64ea20..1955bb2 100644 --- a/tests/snapshots/status_bar__status_bar_with_count.snap +++ b/tests/snapshots/status_bar__status_bar_with_count.snap @@ -4,4 +4,4 @@ expression: result --- - Logged in as testuser repo/owner P ? HELP q/// QUIT Issues: 42 diff --git a/tests/snapshots/text_search__text_search_both_inputs.snap b/tests/snapshots/text_search__text_search_both_inputs.snap index ddb5f81..8f45197 100644 --- a/tests/snapshots/text_search__text_search_both_inputs.snap +++ b/tests/snapshots/text_search__text_search_both_inputs.snap @@ -3,9 +3,9 @@ source: tests/text_search.rs expression: result --- -╭[0] Search────────────────────────────────────────────╮ -│authentication │ -╰──────────────────────────────────────────────────────╯ -╭Search Labels────────────────────────╮╭───────────────╮ -│security;bug ││ ▼ │ -╰─────────────────────────────────────╯╰───────────────╯ +╭[0] Search─────────────────────────────────────────╮ +│authentication │ +╰───────────────────────────────────────────────────╯ +╭Search Labels──────────────────────╮╭──────────────╮ +│security;bug ││ ▼ │ +╰───────────────────────────────────╯╰──────────────╯ diff --git a/tests/snapshots/text_search__text_search_label_input.snap b/tests/snapshots/text_search__text_search_label_input.snap index 3add1a0..30968a3 100644 --- a/tests/snapshots/text_search__text_search_label_input.snap +++ b/tests/snapshots/text_search__text_search_label_input.snap @@ -3,9 +3,9 @@ source: tests/text_search.rs expression: result --- -╭[0] Search────────────────────────────────────────────╮ -│ │ -╰──────────────────────────────────────────────────────╯ -╭Search Labels────────────────────────╮╭───────────────╮ -│priority:high ││ ▼ │ -╰─────────────────────────────────────╯╰───────────────╯ +╭[0] Search─────────────────────────────────────────╮ +│ │ +╰───────────────────────────────────────────────────╯ +╭Search Labels──────────────────────╮╭──────────────╮ +│priority:high ││ ▼ │ +╰───────────────────────────────────╯╰──────────────╯ diff --git a/tests/snapshots/text_search__text_search_loaded_state.snap b/tests/snapshots/text_search__text_search_loaded_state.snap index 2b810d7..78e0541 100644 --- a/tests/snapshots/text_search__text_search_loaded_state.snap +++ b/tests/snapshots/text_search__text_search_loaded_state.snap @@ -3,9 +3,9 @@ source: tests/text_search.rs expression: result --- -╭[0] Search────────────────────────────────────────────╮ -│ │ -╰──────────────────────────────────────────────────────╯ -╭Search Labels────────────────────────╮╭───────────────╮ -│ ││ ▼ │ -╰─────────────────────────────────────╯╰───────────────╯ +╭[0] Search─────────────────────────────────────────╮ +│ │ +╰───────────────────────────────────────────────────╯ +╭Search Labels──────────────────────╮╭──────────────╮ +│ ││ ▼ │ +╰───────────────────────────────────╯╰──────────────╯ diff --git a/tests/snapshots/text_search__text_search_with_input.snap b/tests/snapshots/text_search__text_search_with_input.snap index b846044..9161077 100644 --- a/tests/snapshots/text_search__text_search_with_input.snap +++ b/tests/snapshots/text_search__text_search_with_input.snap @@ -3,9 +3,9 @@ source: tests/text_search.rs expression: result --- -╭[0] Search────────────────────────────────────────────╮ -│bug fix │ -╰──────────────────────────────────────────────────────╯ -╭Search Labels────────────────────────╮╭───────────────╮ -│ ││ ▼ │ -╰─────────────────────────────────────╯╰───────────────╯ +╭[0] Search─────────────────────────────────────────╮ +│bug fix │ +╰───────────────────────────────────────────────────╯ +╭Search Labels──────────────────────╮╭──────────────╮ +│ ││ ▼ │ +╰───────────────────────────────────╯╰──────────────╯ From 916bbeeb920577242d0376dc0f3df55bde6a8990 Mon Sep 17 00:00:00 2001 From: JayanAXHF Date: Wed, 11 Mar 2026 18:38:47 +0530 Subject: [PATCH 3/9] chore: fix lints --- src/ui/components/issue_conversation.rs | 6 +++--- src/ui/components/issue_convo_preview.rs | 17 ++++------------- src/ui/components/issue_list.rs | 3 ++- 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/src/ui/components/issue_conversation.rs b/src/ui/components/issue_conversation.rs index 75901bf..3d5ab19 100644 --- a/src/ui/components/issue_conversation.rs +++ b/src/ui/components/issue_conversation.rs @@ -20,11 +20,11 @@ use rat_widget::{ use ratatui::{ buffer::Buffer, layout::{Rect, Spacing}, - style::{Color, Modifier, Style, Stylize}, + style::{Color, Modifier, Style}, text::{Line, Span, Text}, 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}, @@ -367,7 +367,7 @@ impl IssueConversation { 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 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); diff --git a/src/ui/components/issue_convo_preview.rs b/src/ui/components/issue_convo_preview.rs index dcaf713..9224d42 100644 --- a/src/ui/components/issue_convo_preview.rs +++ b/src/ui/components/issue_convo_preview.rs @@ -9,7 +9,7 @@ use ratatui::{ layout::Rect, widgets::{Block, Borders, StatefulWidget, Widget}, }; -use std::sync::{Arc, RwLock}; +use std::sync::Arc; use textwrap::wrap; use crate::{ @@ -17,7 +17,6 @@ use crate::{ ui::{ Action, components::{Component, help::HelpElementKind, issue_conversation::render_markdown}, - issue_data::{IssueId, UiIssuePool}, layout::Layout, utils::get_border_style, }, @@ -40,11 +39,11 @@ pub const HELP: &[HelpElementKind] = &[ crate::help_keybind!("Esc", "exit fullscreen / return to issue list"), ]; +#[derive(Default)] pub struct IssueConvoPreview { action_tx: Option>, body: Option>, area: Rect, - current: Option, paragraph_state: ParagraphState, index: usize, focus: FocusFlag, @@ -52,15 +51,7 @@ pub struct IssueConvoPreview { impl IssueConvoPreview { pub fn new() -> Self { - Self { - action_tx: None, - current: None, - body: None, - index: 0, - focus: FocusFlag::default(), - area: Rect::default(), - paragraph_state: ParagraphState::default(), - } + Self::default() } pub fn render(&mut self, area: Layout, buf: &mut Buffer) { @@ -81,7 +72,7 @@ impl IssueConvoPreview { return; }; let body_str = wrap( - &body, + body, area.mini_convo_preview.width.saturating_sub(2) as usize, ) .join("\n"); diff --git a/src/ui/components/issue_list.rs b/src/ui/components/issue_list.rs index 2b1e27f..bfc66cf 100644 --- a/src/ui/components/issue_list.rs +++ b/src/ui/components/issue_list.rs @@ -1,3 +1,4 @@ +#![allow(clippy::await_holding_lock)] use crate::{ app::GITHUB_CLIENT, bookmarks::Bookmarks, @@ -1162,7 +1163,7 @@ impl Component for IssueList<'_> { } let (issue_number, labels, preview_seed) = { let pool = self.issue_pool.read().expect("issue pool lock poisoned"); - let issue = pool.get_issue(self.issues[selected].0); + let issue = { pool.get_issue(self.issues[selected].0) }; if let Some(body_id) = issue.body { let body = pool.resolve_str(body_id); self.action_tx From 36e6dbf42da32e3afb2d7a73f0a373fc4a925e6a Mon Sep 17 00:00:00 2001 From: JayanAXHF Date: Sat, 14 Mar 2026 17:27:55 +0530 Subject: [PATCH 4/9] feat(style): migrate over issue_create --- src/ui/components/issue_create.rs | 40 ++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/ui/components/issue_create.rs b/src/ui/components/issue_create.rs index cd7d05b..16f9b71 100644 --- a/src/ui/components/issue_create.rs +++ b/src/ui/components/issue_create.rs @@ -4,6 +4,7 @@ use rat_cursor::HasScreenCursor; use rat_widget::{ event::{HandleEvent, TextOutcome, ct_event}, focus::{FocusBuilder, FocusFlag, HasFocus, Navigation}, + line_number::{LineNumberState, LineNumbers}, paragraph::{Paragraph, ParagraphState}, text_input::{TextInput, TextInputState}, textarea::{TextArea, TextAreaState, TextWrap}, @@ -12,9 +13,9 @@ use ratatui::{ buffer::Buffer, layout::Rect, style::{Color, Style}, - widgets::{Block, StatefulWidget}, + widgets::{Block, Borders, Padding, StatefulWidget}, }; -use ratatui_macros::vertical; +use ratatui_macros::{horizontal, vertical}; use throbber_widgets_tui::{BRAILLE_SIX_DOUBLE, Throbber, ThrobberState, WhichUse}; use crate::{ @@ -77,6 +78,7 @@ pub struct IssueCreate { labels_state: TextInputState, assignees_state: TextInputState, body_state: TextAreaState, + line_number_state: LineNumberState, preview_state: ParagraphState, mode: InputMode, creating: bool, @@ -105,6 +107,7 @@ impl IssueCreate { labels_state: TextInputState::default(), assignees_state: TextInputState::default(), body_state: TextAreaState::new(), + line_number_state: LineNumberState::default(), preview_state: ParagraphState::default(), mode: InputMode::default(), creating: false, @@ -300,19 +303,34 @@ impl IssueCreate { match self.mode { InputMode::Input => { - let mut title = "Body (Ctrl+P: Preview | Ctrl+Enter: Create)".to_string(); - if let Some(err) = &self.error { - title.push_str(" | "); - title.push_str(err); - } - let mut block = Block::bordered() - .border_type(ratatui::widgets::BorderType::Rounded) + let [line_numbers_area, text_area] = horizontal![==self.body_state.len_lines().checked_ilog10().unwrap_or(0) as u16 + 2, *=1] + .areas(body_area); + let line_numbers = LineNumbers::new() + .with_textarea(&self.body_state) + .block( + Block::default() + .borders(Borders::TOP) + .merge_borders(ratatui::symbols::merge::MergeStrategy::Exact) + .border_style(get_border_style(&self.body_state)), + ) + .style(Style::default().dim()); + line_numbers.render(line_numbers_area, buf, &mut self.line_number_state); + + let input_title = if let Some(err) = &self.error { + format!("Body (Ctrl+Enter to create) | {err}") + } else { + "Body (Ctrl+Enter to create)".to_string() + }; + let mut block = Block::default() + .borders(Borders::TOP) + .merge_borders(ratatui::symbols::merge::MergeStrategy::Exact) + .padding(Padding::horizontal(1)) .border_style(get_border_style(&self.body_state)); if !self.creating { - block = block.title(title); + block = block.title(input_title); } let textarea = TextArea::new().block(block).text_wrap(TextWrap::Word(4)); - textarea.render(body_area, buf, &mut self.body_state); + textarea.render(text_area, buf, &mut self.body_state); } InputMode::Preview => { let mut title = "Preview (Ctrl+P: Edit | Ctrl+Enter: Create)".to_string(); From f937a7c4d6c45e9c982e8578d310e242e685c154 Mon Sep 17 00:00:00 2001 From: JayanAXHF Date: Sat, 14 Mar 2026 17:57:42 +0530 Subject: [PATCH 5/9] feat(preview pane): Add a small issue list preview when in issue conversation --- src/ui/components/issue_convo_preview.rs | 439 +++++++++++++++++++++-- src/ui/components/issue_create.rs | 6 + src/ui/components/issue_list.rs | 200 +++++++++-- src/ui/mod.rs | 6 +- 4 files changed, 575 insertions(+), 76 deletions(-) diff --git a/src/ui/components/issue_convo_preview.rs b/src/ui/components/issue_convo_preview.rs index 9224d42..9725b57 100644 --- a/src/ui/components/issue_convo_preview.rs +++ b/src/ui/components/issue_convo_preview.rs @@ -1,87 +1,109 @@ 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::{ + Block, Borders, List as TuiList, ListItem, ListState as TuiListState, Padding, + StatefulWidget, Widget, + }, }; -use std::sync::Arc; +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::DetailsFullscreen | MainScreen::CreateIssue => {} + } + } + + 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 body_str = wrap( - body, - area.mini_convo_preview.width.saturating_sub(2) as usize, - ) - .join("\n"); - let rendered = render_markdown( - &body_str, - area.mini_convo_preview.width.saturating_sub(2).into(), - 2, - ) - .lines; + 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::BOTTOM) @@ -89,7 +111,119 @@ impl IssueConvoPreview { .merge_borders(ratatui::symbols::merge::MergeStrategy::Exact) .border_style(get_border_style(&self.paragraph_state)), ); - para.render(area.mini_convo_preview, buf, &mut 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(()) } } @@ -106,11 +240,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(()) @@ -133,6 +317,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 { @@ -149,4 +352,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