Skip to content

Commit afa6404

Browse files
committed
feat(preview pane): Add a small issue list preview when in issue conversation
fix: render placeholder in create issue mode for convo preview
1 parent c6b23dd commit afa6404

4 files changed

Lines changed: 470 additions & 63 deletions

File tree

src/ui/components/issue_convo_preview.rs

Lines changed: 296 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,115 @@
11
use async_trait::async_trait;
2+
use crossterm::event;
23
use rat_widget::{
3-
event::{HandleEvent, Regular},
4-
focus::{FocusBuilder, FocusFlag, HasFocus},
4+
event::{HandleEvent, Regular, ct_event},
5+
focus::{FocusBuilder, FocusFlag, HasFocus, Navigation},
56
paragraph::ParagraphState,
67
};
78
use ratatui::{
89
buffer::Buffer,
910
layout::Rect,
10-
widgets::{Block, Borders, StatefulWidget, Widget},
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+
},
1117
};
1218
use std::sync::{Arc, RwLock};
1319

1420
use crate::{
1521
errors::AppError,
1622
ui::{
1723
Action,
18-
components::{Component, help::HelpElementKind, issue_conversation::render_markdown},
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},
1932
layout::Layout,
2033
utils::get_border_style,
2134
},
2235
};
2336

2437
pub const HELP: &[HelpElementKind] = &[
25-
crate::help_text!("Issue Conversation Help"),
26-
crate::help_keybind!("Up/Down", "select issue body/comment entry"),
27-
crate::help_keybind!("PageUp/PageDown/Home/End", "scroll message body pane"),
28-
crate::help_keybind!("t", "toggle timeline events"),
29-
crate::help_keybind!("f", "toggle fullscreen body view"),
30-
crate::help_keybind!("C", "close selected issue"),
31-
crate::help_keybind!("l", "copy link to selected message"),
32-
crate::help_keybind!("Enter (popup)", "confirm close reason"),
33-
crate::help_keybind!("Ctrl+P", "toggle comment input/preview"),
34-
crate::help_keybind!("e", "edit selected comment in external editor"),
35-
crate::help_keybind!("r", "add reaction to selected comment"),
36-
crate::help_keybind!("R", "remove reaction from selected comment"),
37-
crate::help_keybind!("Ctrl+Enter / Alt+Enter", "send comment"),
38-
crate::help_keybind!("Esc", "exit fullscreen / return to issue list"),
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"),
3944
];
4045

41-
#[derive(Default)]
4246
pub struct IssueConvoPreview {
4347
action_tx: Option<tokio::sync::mpsc::Sender<Action>>,
48+
issue_pool: Arc<RwLock<UiIssuePool>>,
4449
body: Option<Arc<str>>,
50+
issue_ids: Vec<IssueId>,
51+
open_number: Option<u64>,
52+
selected_number: Option<u64>,
53+
screen: MainScreen,
4554
area: Rect,
4655
paragraph_state: ParagraphState,
56+
list_state: TuiListState,
4757
index: usize,
4858
focus: FocusFlag,
4959
}
5060

5161
impl IssueConvoPreview {
52-
pub fn new() -> Self {
53-
Self::default()
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+
}
5477
}
5578

5679
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) {
57100
let block_template = Block::default()
58101
.borders(Borders::LEFT | Borders::BOTTOM)
59102
.border_style(get_border_style(&self.paragraph_state));
60103

61-
self.area = area.mini_convo_preview;
62104
let Some(ref body) = self.body else {
63105
let para =
64106
ratatui::widgets::Paragraph::new("Select an issue to preview the conversation")
65107
.block(
66108
block_template
67-
.title(format!("[{}] Issue Conversation]", self.index))
109+
.title(format!("[{}] Issue Conversation", self.index))
68110
.merge_borders(ratatui::symbols::merge::MergeStrategy::Exact),
69111
);
70-
para.render(area.mini_convo_preview, buf);
112+
para.render(area, buf);
71113
return;
72114
};
73115
let rendered = render_markdown(body, area.width.saturating_sub(2).into(), 2).lines;
@@ -201,11 +243,61 @@ impl Component for IssueConvoPreview {
201243
async fn handle_event(&mut self, event: Action) -> Result<(), AppError> {
202244
match event {
203245
Action::AppEvent(ref event) => {
204-
self.paragraph_state.handle(event, Regular);
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+
}
205282
}
206283
Action::ChangeIssueBodyPreview(body) => {
207284
self.body = Some(body);
208285
}
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+
}
209301
_ => {}
210302
}
211303
Ok(())
@@ -228,6 +320,25 @@ impl Component for IssueConvoPreview {
228320
let _ = action_tx.try_send(Action::SetHelp(HELP));
229321
}
230322
}
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+
}
231342
}
232343

233344
impl HasFocus for IssueConvoPreview {
@@ -244,4 +355,164 @@ impl HasFocus for IssueConvoPreview {
244355
fn area(&self) -> Rect {
245356
self.area
246357
}
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)]
369+
mod 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+
}
247518
}

0 commit comments

Comments
 (0)