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}