11use async_trait:: async_trait;
2+ use crossterm:: event;
23use 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} ;
78use 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} ;
1218use std:: sync:: { Arc , RwLock } ;
1319
1420use 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
2437pub 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 ) ]
4246pub 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
5161impl 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
233344impl 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