Skip to main content

gitv_tui/ui/components/
issue_convo_preview.rs

1use async_trait::async_trait;
2use crossterm::event;
3use rat_widget::{
4    event::{HandleEvent, Regular, ct_event},
5    focus::{FocusBuilder, FocusFlag, HasFocus, Navigation},
6    paragraph::ParagraphState,
7};
8use ratatui::{
9    buffer::Buffer,
10    layout::Rect,
11    style::{Color, Modifier, Style},
12    text::Span,
13    widgets::{
14        self, Block, Borders, List as TuiList, ListItem, ListState as TuiListState, Padding,
15        StatefulWidget, Widget,
16    },
17};
18use std::sync::{Arc, RwLock};
19
20use crate::{
21    errors::AppError,
22    ui::{
23        Action,
24        components::{
25            Component,
26            help::HelpElementKind,
27            issue_conversation::render_markdown,
28            issue_detail::IssuePreviewSeed,
29            issue_list::{MainScreen, build_issue_list_item, build_issue_list_lines},
30        },
31        issue_data::{IssueId, UiIssuePool},
32        layout::Layout,
33        utils::get_border_style,
34    },
35};
36
37pub const HELP: &[HelpElementKind] = &[
38    crate::help_text!("Issue Conversation Preview Help"),
39    crate::help_text!("* marks the issue currently open in details"),
40    crate::help_keybind!("Up/Down", "select nearby issue"),
41    crate::help_keybind!("Enter", "open selected issue"),
42    crate::help_keybind!("Tab", "move focus forward"),
43    crate::help_keybind!("Shift+Tab / Esc", "move focus back"),
44];
45
46pub struct IssueConvoPreview {
47    action_tx: Option<tokio::sync::mpsc::Sender<Action>>,
48    issue_pool: Arc<RwLock<UiIssuePool>>,
49    body: Option<Arc<str>>,
50    issue_ids: Vec<IssueId>,
51    open_number: Option<u64>,
52    selected_number: Option<u64>,
53    screen: MainScreen,
54    area: Rect,
55    paragraph_state: ParagraphState,
56    list_state: TuiListState,
57    index: usize,
58    focus: FocusFlag,
59}
60
61impl IssueConvoPreview {
62    pub fn new(issue_pool: Arc<RwLock<UiIssuePool>>) -> Self {
63        Self {
64            action_tx: None,
65            issue_pool,
66            body: None,
67            issue_ids: Vec::new(),
68            open_number: None,
69            selected_number: None,
70            screen: MainScreen::List,
71            area: Rect::default(),
72            paragraph_state: ParagraphState::default(),
73            list_state: TuiListState::default(),
74            index: 0,
75            focus: FocusFlag::new().with_name("issue_convo_preview"),
76        }
77    }
78
79    pub fn render(&mut self, area: Layout, buf: &mut Buffer) {
80        self.area = area.mini_convo_preview;
81        match self.screen {
82            MainScreen::List => self.render_body_preview(area.mini_convo_preview, buf),
83            MainScreen::Details => self.render_issue_list_preview(area.mini_convo_preview, buf),
84            MainScreen::CreateIssue => {
85                let para = widgets::Paragraph::new("No preview available in fullscreen mode")
86                    .block(
87                        Block::default()
88                            .borders(Borders::LEFT | Borders::BOTTOM)
89                            .title(format!("[{}] Issue Conversation", self.index))
90                            .merge_borders(ratatui::symbols::merge::MergeStrategy::Exact)
91                            .border_style(get_border_style(&self.paragraph_state)),
92                    );
93                para.render(area.mini_convo_preview, buf);
94            }
95            MainScreen::DetailsFullscreen => {}
96        }
97    }
98
99    fn render_body_preview(&mut self, area: Rect, buf: &mut Buffer) {
100        let block_template = Block::default()
101            .borders(Borders::LEFT | Borders::BOTTOM)
102            .border_style(get_border_style(&self.paragraph_state));
103
104        let Some(ref body) = self.body else {
105            let para =
106                ratatui::widgets::Paragraph::new("Select an issue to preview the conversation")
107                    .block(
108                        block_template
109                            .title(format!("[{}] Issue Conversation", self.index))
110                            .merge_borders(ratatui::symbols::merge::MergeStrategy::Exact),
111                    );
112            para.render(area, buf);
113            return;
114        };
115        let rendered = render_markdown(body, area.width.saturating_sub(2).into(), 2).lines;
116        let para = rat_widget::paragraph::Paragraph::new(rendered).block(
117            Block::default()
118                .borders(Borders::LEFT | Borders::TOP | Borders::BOTTOM)
119                .title(format!("[{}] Issue Body", self.index))
120                .merge_borders(ratatui::symbols::merge::MergeStrategy::Exact)
121                .border_style(get_border_style(&self.paragraph_state)),
122        );
123        para.render(area, buf, &mut self.paragraph_state);
124    }
125
126    fn render_issue_list_preview(&mut self, area: Rect, buf: &mut Buffer) {
127        let block = Block::default()
128            .borders(Borders::LEFT | Borders::BOTTOM)
129            .padding(Padding::horizontal(1))
130            .title(format!("[{}] Nearby Issues", self.index))
131            .merge_borders(ratatui::symbols::merge::MergeStrategy::Exact)
132            .border_style(get_border_style(&self.paragraph_state));
133
134        if self.issue_ids.is_empty() {
135            let para = ratatui::widgets::Paragraph::new("No nearby issues available.").block(block);
136            para.render(area, buf);
137            return;
138        }
139
140        let items = {
141            let pool = self.issue_pool.read().expect("issue pool lock poisoned");
142            self.issue_ids
143                .iter()
144                .map(|issue_id| {
145                    let issue = pool.get_issue(*issue_id);
146                    if Some(issue.number) == self.open_number {
147                        let mut lines = build_issue_list_lines(issue, &pool, false, false);
148                        if let Some(first_line) = lines.first_mut() {
149                            first_line.spans.insert(
150                                0,
151                                Span::styled(
152                                    "* ",
153                                    Style::new().fg(Color::Green).add_modifier(Modifier::BOLD),
154                                ),
155                            );
156                        }
157                        ListItem::new(lines)
158                    } else {
159                        build_issue_list_item(issue, &pool, false, false)
160                    }
161                })
162                .collect::<Vec<_>>()
163        };
164
165        self.sync_selected_issue();
166
167        let list = TuiList::new(items)
168            .block(block)
169            .highlight_style(Style::new().add_modifier(Modifier::BOLD | Modifier::REVERSED));
170        StatefulWidget::render(list, area, buf, &mut self.list_state);
171    }
172
173    fn selected_issue_id(&self) -> Option<IssueId> {
174        let selected = self.list_state.selected()?;
175        self.issue_ids.get(selected).copied()
176    }
177
178    fn sync_selected_issue(&mut self) {
179        let selected = self.selected_number.and_then(|number| {
180            let pool = self.issue_pool.read().expect("issue pool lock poisoned");
181            self.issue_ids
182                .iter()
183                .position(|issue_id| pool.get_issue(*issue_id).number == number)
184        });
185        self.list_state.select(selected);
186    }
187
188    async fn open_selected_issue(&mut self) -> Result<(), AppError> {
189        let Some(issue_id) = self.selected_issue_id() else {
190            return Ok(());
191        };
192        let Some(action_tx) = self.action_tx.clone() else {
193            return Ok(());
194        };
195
196        let (number, labels, preview_seed, conversation_seed) = {
197            let pool = self.issue_pool.read().expect("issue pool lock poisoned");
198            let issue = pool.get_issue(issue_id);
199            (
200                issue.number,
201                issue.labels.clone(),
202                IssuePreviewSeed::from_ui_issue(issue, &pool),
203                crate::ui::components::issue_conversation::IssueConversationSeed::from_ui_issue(
204                    issue, &pool,
205                ),
206            )
207        };
208
209        self.open_number = Some(number);
210        self.selected_number = Some(number);
211        self.sync_selected_issue();
212        action_tx
213            .send(Action::SelectedIssue { number, labels })
214            .await?;
215        action_tx
216            .send(Action::SelectedIssuePreview { seed: preview_seed })
217            .await?;
218        action_tx
219            .send(Action::IssueListPreviewUpdated {
220                issue_ids: self.issue_ids.clone(),
221                selected_number: number,
222            })
223            .await?;
224        action_tx
225            .send(Action::EnterIssueDetails {
226                seed: conversation_seed,
227            })
228            .await?;
229        Ok(())
230    }
231}
232
233#[async_trait(?Send)]
234impl Component for IssueConvoPreview {
235    fn render(&mut self, area: Layout, buf: &mut Buffer) {
236        self.render(area, buf);
237    }
238
239    fn register_action_tx(&mut self, action_tx: tokio::sync::mpsc::Sender<Action>) {
240        self.action_tx = Some(action_tx);
241    }
242
243    async fn handle_event(&mut self, event: Action) -> Result<(), AppError> {
244        match event {
245            Action::AppEvent(ref event) => {
246                if self.screen == MainScreen::List {
247                    self.paragraph_state.handle(event, Regular);
248                } else if self.screen == MainScreen::Details && self.paragraph_state.is_focused() {
249                    match event {
250                        ct_event!(keycode press Up) => {
251                            self.list_state.select_previous();
252                            self.selected_number = self.selected_issue_id().map(|issue_id| {
253                                let pool =
254                                    self.issue_pool.read().expect("issue pool lock poisoned");
255                                pool.get_issue(issue_id).number
256                            });
257                        }
258                        ct_event!(keycode press Down) => {
259                            self.list_state.select_next();
260                            self.selected_number = self.selected_issue_id().map(|issue_id| {
261                                let pool =
262                                    self.issue_pool.read().expect("issue pool lock poisoned");
263                                pool.get_issue(issue_id).number
264                            });
265                        }
266                        ct_event!(keycode press Enter) => {
267                            self.open_selected_issue().await?;
268                        }
269                        ct_event!(keycode press Tab) => {
270                            if let Some(action_tx) = self.action_tx.as_ref() {
271                                action_tx.send(Action::ForceFocusChange).await?;
272                            }
273                        }
274                        ct_event!(keycode press SHIFT-BackTab) | ct_event!(keycode press Esc) => {
275                            if let Some(action_tx) = self.action_tx.as_ref() {
276                                action_tx.send(Action::ForceFocusChangeRev).await?;
277                            }
278                        }
279                        _ => {}
280                    }
281                }
282            }
283            Action::ChangeIssueBodyPreview(body) => {
284                self.body = Some(body);
285            }
286            Action::IssueListPreviewUpdated {
287                issue_ids,
288                selected_number,
289            } => {
290                self.issue_ids = issue_ids;
291                self.open_number = Some(selected_number);
292                self.selected_number = Some(selected_number);
293                self.sync_selected_issue();
294            }
295            Action::ChangeIssueScreen(screen) => {
296                self.screen = screen;
297                if screen != MainScreen::Details {
298                    self.paragraph_state.focus.set(false);
299                }
300            }
301            _ => {}
302        }
303        Ok(())
304    }
305
306    fn should_render(&self) -> bool {
307        true
308    }
309
310    fn is_animating(&self) -> bool {
311        false
312    }
313
314    fn set_index(&mut self, index: usize) {
315        self.index = index;
316    }
317
318    fn set_global_help(&self) {
319        if let Some(action_tx) = &self.action_tx {
320            let _ = action_tx.try_send(Action::SetHelp(HELP));
321        }
322    }
323
324    fn capture_focus_event(&self, event: &event::Event) -> bool {
325        if self.screen != MainScreen::Details || !self.paragraph_state.is_focused() {
326            return false;
327        }
328
329        match event {
330            event::Event::Key(key) => matches!(
331                key.code,
332                event::KeyCode::Up
333                    | event::KeyCode::Down
334                    | event::KeyCode::Enter
335                    | event::KeyCode::Tab
336                    | event::KeyCode::BackTab
337                    | event::KeyCode::Esc
338            ),
339            _ => false,
340        }
341    }
342}
343
344impl HasFocus for IssueConvoPreview {
345    fn build(&self, builder: &mut FocusBuilder) {
346        let tag = builder.start(self);
347        builder.widget(&self.paragraph_state);
348        builder.end(tag);
349    }
350
351    fn focus(&self) -> FocusFlag {
352        self.focus.clone()
353    }
354
355    fn area(&self) -> Rect {
356        self.area
357    }
358
359    fn navigable(&self) -> Navigation {
360        if self.screen == MainScreen::Details {
361            Navigation::Regular
362        } else {
363            Navigation::None
364        }
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use crate::ui::testing::{DummyDataConfig, dummy_ui_data_with};
372    use octocrab::models::Label;
373    use ratatui::{buffer::Buffer, layout::Rect};
374    use tokio::sync::mpsc;
375
376    fn buffer_text(buf: &Buffer) -> String {
377        let area = buf.area;
378        (area.top()..area.bottom())
379            .map(|y| {
380                (area.left()..area.right())
381                    .map(|x| buf[(x, y)].symbol())
382                    .collect::<String>()
383            })
384            .collect::<Vec<_>>()
385            .join("\n")
386    }
387
388    #[test]
389    fn renders_body_preview_in_list_mode() {
390        let data = dummy_ui_data_with(DummyDataConfig {
391            issue_count: 3,
392            ..DummyDataConfig::default()
393        });
394        let pool = Arc::new(RwLock::new(data.pool));
395        let mut preview = IssueConvoPreview::new(pool);
396        preview.body = Some(Arc::<str>::from("hello from preview body"));
397
398        let mut buf = Buffer::empty(Rect::new(0, 0, 80, 24));
399        preview.render(Layout::fullscreen(Rect::new(0, 0, 80, 24)), &mut buf);
400
401        let text = buffer_text(&buf);
402        assert!(text.contains("Issue Body"));
403        assert!(text.contains("hello from preview body"));
404    }
405
406    #[test]
407    fn renders_nearby_issues_in_details_mode() {
408        let data = dummy_ui_data_with(DummyDataConfig {
409            issue_count: 4,
410            ..DummyDataConfig::default()
411        });
412        let selected_id = data.issue_ids[1];
413        let open_number = data.issue_numbers[1];
414        let selected_number = data.issue_numbers[2];
415        let pool = Arc::new(RwLock::new(data.pool));
416        let mut preview = IssueConvoPreview::new(pool);
417        preview.screen = MainScreen::Details;
418        preview.issue_ids = data.issue_ids.clone();
419        preview.open_number = Some(open_number);
420        preview.selected_number = Some(selected_number);
421        preview.sync_selected_issue();
422
423        let mut buf = Buffer::empty(Rect::new(0, 0, 80, 24));
424        preview.render(Layout::fullscreen(Rect::new(0, 0, 80, 24)), &mut buf);
425
426        let text = buffer_text(&buf);
427        assert!(text.contains("Nearby Issues"));
428        assert!(text.contains(&format!("#{open_number}")));
429        assert!(text.contains(&format!("#{selected_number}")));
430
431        let pool = preview.issue_pool.read().expect("issue pool lock poisoned");
432        let open_title = pool.resolve_str(pool.get_issue(selected_id).title);
433        let selected_title = pool.resolve_str(pool.get_issue(data.issue_ids[2]).title);
434        assert!(text.contains(&format!("* {open_title}")));
435        assert!(!text.contains(&format!("* {selected_title}")));
436    }
437
438    #[test]
439    fn renders_nothing_in_fullscreen_mode() {
440        let data = dummy_ui_data_with(DummyDataConfig::default());
441        let pool = Arc::new(RwLock::new(data.pool));
442        let mut preview = IssueConvoPreview::new(pool);
443        preview.screen = MainScreen::DetailsFullscreen;
444
445        let mut buf = Buffer::empty(Rect::new(0, 0, 80, 24));
446        preview.render(Layout::fullscreen(Rect::new(0, 0, 80, 24)), &mut buf);
447
448        let text = buffer_text(&buf);
449        assert!(text.trim().is_empty());
450    }
451
452    #[tokio::test]
453    async fn opens_selected_issue_from_preview() {
454        let data = dummy_ui_data_with(DummyDataConfig {
455            issue_count: 4,
456            ..DummyDataConfig::default()
457        });
458        let selected_id = data.issue_ids[1];
459        let selected_number = data.issue_numbers[1];
460        let expected_author = data
461            .preview_seeds
462            .get(&selected_id)
463            .expect("preview seed should exist")
464            .author
465            .clone();
466        let expected_labels: Vec<Label> = {
467            let issue = data.pool.get_issue(selected_id);
468            issue.labels.clone()
469        };
470        let pool = Arc::new(RwLock::new(data.pool));
471        let mut preview = IssueConvoPreview::new(pool);
472        let (tx, mut rx) = mpsc::channel(8);
473        preview.register_action_tx(tx);
474        preview.screen = MainScreen::Details;
475        preview.issue_ids = data.issue_ids.clone();
476        preview.selected_number = Some(selected_number);
477        preview.sync_selected_issue();
478
479        preview
480            .open_selected_issue()
481            .await
482            .expect("open should succeed");
483
484        match rx.recv().await.expect("selected issue action") {
485            Action::SelectedIssue { number, labels } => {
486                assert_eq!(number, selected_number);
487                assert_eq!(labels, expected_labels);
488            }
489            other => panic!("unexpected action: {other:?}"),
490        }
491
492        match rx.recv().await.expect("selected issue preview action") {
493            Action::SelectedIssuePreview { seed } => {
494                assert_eq!(seed.number, selected_number);
495                assert_eq!(seed.author, expected_author);
496            }
497            other => panic!("unexpected action: {other:?}"),
498        }
499
500        match rx.recv().await.expect("preview refresh action") {
501            Action::IssueListPreviewUpdated {
502                issue_ids,
503                selected_number: number,
504            } => {
505                assert_eq!(number, selected_number);
506                assert_eq!(issue_ids, data.issue_ids);
507            }
508            other => panic!("unexpected action: {other:?}"),
509        }
510
511        match rx.recv().await.expect("enter details action") {
512            Action::EnterIssueDetails { seed } => {
513                assert_eq!(seed.number, selected_number);
514            }
515            other => panic!("unexpected action: {other:?}"),
516        }
517    }
518}