Skip to main content

gitv_tui/ui/components/
issue_conversation.rs

1use async_trait::async_trait;
2use crossterm::event;
3use futures::{StreamExt, stream};
4use octocrab::models::{
5    CommentId, Event as IssueEvent, IssueState, issues::Comment as ApiComment,
6    reactions::ReactionContent, timelines::TimelineEvent,
7};
8use pulldown_cmark::{
9    BlockQuoteKind, CodeBlockKind, Event as MdEvent, Options, Parser, Tag, TagEnd, TextMergeStream,
10};
11use rat_cursor::HasScreenCursor;
12use rat_widget::{
13    event::{HandleEvent, Outcome, TextOutcome, ct_event},
14    focus::{FocusBuilder, FocusFlag, HasFocus, Navigation},
15    list::{ListState, selection::RowSelection},
16    paragraph::{Paragraph, ParagraphState},
17    textarea::{TextArea, TextAreaState, TextWrap},
18};
19use ratatui::{
20    buffer::Buffer,
21    layout::Rect,
22    style::{Color, Modifier, Style, Stylize},
23    text::{Line, Span, Text},
24    widgets::{self, Block, ListItem, StatefulWidget, Widget},
25};
26use ratatui_macros::{horizontal, line, span, vertical};
27use std::{
28    collections::{HashMap, HashSet},
29    sync::{Arc, OnceLock},
30};
31use syntect::{
32    easy::HighlightLines,
33    highlighting::{FontStyle, Theme, ThemeSet},
34    parsing::{SyntaxReference, SyntaxSet},
35};
36use textwrap::{core::display_width, wrap};
37use throbber_widgets_tui::{BRAILLE_SIX_DOUBLE, Throbber, ThrobberState, WhichUse};
38use tracing::trace;
39
40use crate::{
41    app::GITHUB_CLIENT,
42    errors::AppError,
43    ui::{
44        Action,
45        components::{
46            Component,
47            help::HelpElementKind,
48            issue_list::{IssueClosePopupState, MainScreen, render_issue_close_popup},
49        },
50        layout::Layout,
51        toast_action,
52        utils::get_border_style,
53    },
54};
55use anyhow::anyhow;
56use hyperrat::Link;
57use ratatui_toaster::{ToastPosition, ToastType};
58
59pub const HELP: &[HelpElementKind] = &[
60    crate::help_text!("Issue Conversation Help"),
61    crate::help_keybind!("Up/Down", "select issue body/comment entry"),
62    crate::help_keybind!("PageUp/PageDown/Home/End", "scroll message body pane"),
63    crate::help_keybind!("t", "toggle timeline events"),
64    crate::help_keybind!("f", "toggle fullscreen body view"),
65    crate::help_keybind!("C", "close selected issue"),
66    crate::help_keybind!("l", "copy link to selected message"),
67    crate::help_keybind!("Enter (popup)", "confirm close reason"),
68    crate::help_keybind!("Ctrl+P", "toggle comment input/preview"),
69    crate::help_keybind!("e", "edit selected comment in external editor"),
70    crate::help_keybind!("r", "add reaction to selected comment"),
71    crate::help_keybind!("R", "remove reaction from selected comment"),
72    crate::help_keybind!("Ctrl+Enter / Alt+Enter", "send comment"),
73    crate::help_keybind!("Esc", "exit fullscreen / return to issue list"),
74];
75
76struct SyntectAssets {
77    syntaxes: SyntaxSet,
78    theme: Theme,
79}
80
81static SYNTECT_ASSETS: OnceLock<SyntectAssets> = OnceLock::new();
82
83fn syntect_assets() -> &'static SyntectAssets {
84    SYNTECT_ASSETS.get_or_init(|| {
85        let syntaxes = SyntaxSet::load_defaults_nonewlines();
86        let theme_set = ThemeSet::load_defaults();
87        let theme = theme_set
88            .themes
89            .get("base16-ocean.dark")
90            .or_else(|| theme_set.themes.values().next())
91            .cloned()
92            .expect("syntect default theme set should include at least one theme");
93        SyntectAssets { syntaxes, theme }
94    })
95}
96
97#[derive(Debug, Clone)]
98pub struct IssueConversationSeed {
99    pub number: u64,
100    pub author: Arc<str>,
101    pub created_at: Arc<str>,
102    pub created_ts: i64,
103    pub body: Option<Arc<str>>,
104    pub title: Option<Arc<str>>,
105}
106
107impl IssueConversationSeed {
108    pub fn from_issue(issue: &octocrab::models::issues::Issue) -> Self {
109        Self {
110            number: issue.number,
111            author: Arc::<str>::from(issue.user.login.as_str()),
112            created_at: Arc::<str>::from(issue.created_at.format("%Y-%m-%d %H:%M").to_string()),
113            created_ts: issue.created_at.timestamp(),
114            body: issue.body.as_ref().map(|b| Arc::<str>::from(b.as_str())),
115            title: Some(Arc::<str>::from(issue.title.as_str())),
116        }
117    }
118}
119
120#[derive(Debug, Clone)]
121pub struct CommentView {
122    pub id: u64,
123    pub author: Arc<str>,
124    pub created_at: Arc<str>,
125    pub created_ts: i64,
126    pub body: Arc<str>,
127    pub reactions: Option<Vec<(ReactionContent, u64)>>,
128    pub my_reactions: Option<Vec<ReactionContent>>,
129}
130
131impl CommentView {
132    pub fn from_api(comment: ApiComment) -> Self {
133        let body = comment.body.unwrap_or_default();
134        Self {
135            id: comment.id.0,
136            author: Arc::<str>::from(comment.user.login.as_str()),
137            created_at: Arc::<str>::from(comment.created_at.format("%Y-%m-%d %H:%M").to_string()),
138            created_ts: comment.created_at.timestamp(),
139            body: Arc::<str>::from(body),
140            reactions: None,
141            my_reactions: None,
142        }
143    }
144}
145
146#[derive(Debug, Clone)]
147pub struct TimelineEventView {
148    pub id: u64,
149    pub created_at: Arc<str>,
150    pub created_ts: i64,
151    pub actor: Arc<str>,
152    pub event: IssueEvent,
153    pub icon: &'static str,
154    pub summary: Arc<str>,
155    pub details: Arc<str>,
156}
157
158impl TimelineEventView {
159    fn from_api(event: TimelineEvent, fallback_id: u64) -> Option<Self> {
160        if matches!(
161            event.event,
162            IssueEvent::Commented | IssueEvent::LineCommented | IssueEvent::CommentDeleted
163        ) {
164            return None;
165        }
166
167        let id = event.id.map(|id| id.0).unwrap_or(fallback_id);
168        let when = event.created_at.or(event.updated_at).or(event.submitted_at);
169        let created_ts = when.map(|d| d.timestamp()).unwrap_or(0);
170        let created_at = Arc::<str>::from(
171            when.map(|d| d.format("%Y-%m-%d %H:%M").to_string())
172                .unwrap_or_else(|| "unknown time".to_string()),
173        );
174        let actor = event
175            .actor
176            .as_ref()
177            .or(event.user.as_ref())
178            .map(|a| Arc::<str>::from(a.login.as_str()))
179            .unwrap_or_else(|| Arc::<str>::from("github"));
180        let (icon, action) = timeline_event_meta(&event.event);
181        let details = timeline_event_details(&event);
182        let summary = Arc::<str>::from(format!("{} {}", actor.as_ref(), action));
183
184        Some(Self {
185            id,
186            created_at,
187            created_ts,
188            actor,
189            event: event.event,
190            icon,
191            summary,
192            details: Arc::<str>::from(details),
193        })
194    }
195}
196
197pub struct IssueConversation {
198    title: Option<Arc<str>>,
199    action_tx: Option<tokio::sync::mpsc::Sender<Action>>,
200    current: Option<IssueConversationSeed>,
201    cache_number: Option<u64>,
202    cache_comments: Vec<CommentView>,
203    timeline_cache_number: Option<u64>,
204    cache_timeline: Vec<TimelineEventView>,
205    markdown_cache: HashMap<u64, MarkdownRender>,
206    body_cache: Option<MarkdownRender>,
207    body_cache_number: Option<u64>,
208    markdown_width: usize,
209    loading: HashSet<u64>,
210    timeline_loading: HashSet<u64>,
211    posting: bool,
212    error: Option<String>,
213    post_error: Option<String>,
214    reaction_error: Option<String>,
215    close_error: Option<String>,
216    timeline_error: Option<String>,
217    owner: String,
218    repo: String,
219    current_user: String,
220    list_state: ListState<RowSelection>,
221    message_keys: Vec<MessageKey>,
222    show_timeline: bool,
223    input_state: TextAreaState,
224    throbber_state: ThrobberState,
225    post_throbber_state: ThrobberState,
226    screen: MainScreen,
227    focus: FocusFlag,
228    area: Rect,
229    textbox_state: InputState,
230    paragraph_state: ParagraphState,
231    body_paragraph_state: ParagraphState,
232    reaction_mode: Option<ReactionMode>,
233    close_popup: Option<IssueClosePopupState>,
234    index: usize,
235}
236
237#[derive(Debug, Default, PartialEq, Eq)]
238enum InputState {
239    #[default]
240    Input,
241    Preview,
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245enum MessageKey {
246    IssueBody(u64),
247    Comment(u64),
248    Timeline(u64),
249}
250
251#[derive(Debug, Clone, Default)]
252struct MarkdownRender {
253    lines: Vec<Line<'static>>,
254    links: Vec<RenderedLink>,
255}
256
257#[derive(Debug, Clone)]
258struct RenderedLink {
259    line: usize,
260    col: usize,
261    label: String,
262    url: String,
263    width: usize,
264}
265
266#[derive(Debug, Clone)]
267enum ReactionMode {
268    Add {
269        comment_id: u64,
270        selected: usize,
271    },
272    Remove {
273        comment_id: u64,
274        selected: usize,
275        options: Vec<ReactionContent>,
276    },
277}
278
279impl InputState {
280    fn toggle(&mut self) {
281        *self = match self {
282            InputState::Input => InputState::Preview,
283            InputState::Preview => InputState::Input,
284        };
285    }
286}
287
288impl IssueConversation {
289    fn in_details_mode(&self) -> bool {
290        matches!(
291            self.screen,
292            MainScreen::Details | MainScreen::DetailsFullscreen
293        )
294    }
295
296    pub fn new(app_state: crate::ui::AppState) -> Self {
297        Self {
298            title: None,
299            action_tx: None,
300            current: None,
301            cache_number: None,
302            cache_comments: Vec::new(),
303            timeline_cache_number: None,
304            cache_timeline: Vec::new(),
305            markdown_cache: HashMap::new(),
306            paragraph_state: Default::default(),
307            body_cache: None,
308            body_cache_number: None,
309            markdown_width: 0,
310            loading: HashSet::new(),
311            timeline_loading: HashSet::new(),
312            posting: false,
313            error: None,
314            post_error: None,
315            reaction_error: None,
316            close_error: None,
317            timeline_error: None,
318            owner: app_state.owner,
319            repo: app_state.repo,
320            current_user: app_state.current_user,
321            list_state: ListState::default(),
322            message_keys: Vec::new(),
323            show_timeline: false,
324            input_state: TextAreaState::new(),
325            textbox_state: InputState::default(),
326            throbber_state: ThrobberState::default(),
327            post_throbber_state: ThrobberState::default(),
328            screen: MainScreen::default(),
329            focus: FocusFlag::new().with_name("issue_conversation"),
330            area: Rect::default(),
331            body_paragraph_state: ParagraphState::default(),
332            reaction_mode: None,
333            close_popup: None,
334            index: 0,
335        }
336    }
337
338    pub fn render(&mut self, area: Layout, buf: &mut Buffer) {
339        if self.screen == MainScreen::DetailsFullscreen {
340            self.area = area.main_content;
341            self.render_body(area.main_content, buf);
342            return;
343        }
344        self.area = area.main_content;
345        let title = self.title.clone().unwrap_or_default();
346        let wrapped_title = wrap(&title, area.main_content.width.saturating_sub(2) as usize);
347        let title_para_height = wrapped_title.len() as u16 + 2;
348        let last_item = wrapped_title.last();
349        let last_line = last_item
350            .as_ref()
351            .map(|l| {
352                line![
353                    l.to_string(),
354                    span!(
355                        " #{}",
356                        self.current.as_ref().map(|s| s.number).unwrap_or_default()
357                    )
358                    .dim()
359                ]
360            })
361            .unwrap_or_else(|| Line::from(""));
362        let wrapped_title_len = wrapped_title.len() as u16;
363        let title_para = Text::from_iter(
364            wrapped_title
365                .into_iter()
366                .take(wrapped_title_len as usize - 1)
367                .map(Line::from)
368                .chain(std::iter::once(last_line)),
369        );
370
371        let areas = vertical![==title_para_height, *=1, ==5].split(area.main_content);
372        let title_area = areas[0];
373        let content_area = areas[1];
374        let input_area = areas[2];
375        let content_split = horizontal![*=1, *=1].split(content_area);
376        let list_area = content_split[0];
377        let body_area = content_split[1];
378        let items = self.build_items(list_area, body_area);
379
380        let title_widget = widgets::Paragraph::new(title_para)
381            .block(Block::bordered().border_type(ratatui::widgets::BorderType::Rounded))
382            .style(Style::default().add_modifier(Modifier::BOLD));
383        title_widget.render(title_area, buf);
384
385        let mut list_block = Block::bordered()
386            .border_type(ratatui::widgets::BorderType::Rounded)
387            .border_style(get_border_style(&self.list_state));
388
389        if !self.is_loading_current() {
390            let mut title = format!("[{}] Conversation", self.index);
391            title.push_str(if self.show_timeline {
392                " | Timeline: ON"
393            } else {
394                " | Timeline: OFF"
395            });
396            if let Some(prompt) = self.reaction_mode_prompt() {
397                title.push_str(" | ");
398                title.push_str(&prompt);
399            } else if let Some(err) = &self.reaction_error {
400                title.push_str(" | ");
401                title.push_str(err);
402            } else if let Some(err) = &self.close_error {
403                title.push_str(" | ");
404                title.push_str(err);
405            } else if let Some(err) = &self.timeline_error {
406                title.push_str(" | ");
407                title.push_str(err);
408            }
409            list_block = list_block.title(title);
410        }
411
412        let list = rat_widget::list::List::<RowSelection>::new(items)
413            .block(list_block)
414            .style(Style::default())
415            .focus_style(Style::default().bold().reversed())
416            .select_style(Style::default().add_modifier(Modifier::BOLD));
417        list.render(list_area, buf, &mut self.list_state);
418        self.render_body(body_area, buf);
419        if self.is_loading_current() {
420            let title_area = Rect {
421                x: list_area.x + 1,
422                y: list_area.y,
423                width: 10,
424                height: 1,
425            };
426            let throbber = Throbber::default()
427                .label("Loading")
428                .style(Style::new().fg(Color::Cyan))
429                .throbber_set(BRAILLE_SIX_DOUBLE)
430                .use_type(WhichUse::Spin);
431            StatefulWidget::render(throbber, title_area, buf, &mut self.throbber_state);
432        }
433
434        match self.textbox_state {
435            InputState::Input => {
436                let input_title = if let Some(err) = &self.post_error {
437                    format!("Comment (Ctrl+Enter to send) | {err}")
438                } else {
439                    "Comment (Ctrl+Enter to send)".to_string()
440                };
441                let mut input_block = Block::bordered()
442                    .border_type(ratatui::widgets::BorderType::Rounded)
443                    .border_style(get_border_style(&self.input_state));
444                if !self.posting {
445                    input_block = input_block.title(input_title);
446                }
447                let input_widget = TextArea::new()
448                    .block(input_block)
449                    .text_wrap(TextWrap::Word(4));
450                input_widget.render(input_area, buf, &mut self.input_state);
451            }
452            InputState::Preview => {
453                let rendered =
454                    render_markdown_lines(&self.input_state.text(), self.markdown_width, 2);
455                let para = Paragraph::new(rendered)
456                    .block(
457                        Block::bordered()
458                            .border_type(ratatui::widgets::BorderType::Rounded)
459                            .border_style(get_border_style(&self.paragraph_state))
460                            .title("Preview"),
461                    )
462                    .focus_style(Style::default())
463                    .hide_focus(true)
464                    .wrap(ratatui::widgets::Wrap { trim: true });
465
466                para.render(input_area, buf, &mut self.paragraph_state);
467            }
468        }
469
470        if self.posting {
471            let title_area = Rect {
472                x: input_area.x + 1,
473                y: input_area.y,
474                width: 10,
475                height: 1,
476            };
477            let throbber = Throbber::default()
478                .label("Sending")
479                .style(Style::new().fg(Color::Cyan))
480                .throbber_set(BRAILLE_SIX_DOUBLE)
481                .use_type(WhichUse::Spin);
482            StatefulWidget::render(throbber, title_area, buf, &mut self.post_throbber_state);
483        }
484        self.render_close_popup(area.main_content, buf);
485    }
486
487    fn build_items(&mut self, list_area: Rect, body_area: Rect) -> Vec<ListItem<'static>> {
488        let mut items = Vec::new();
489        let width = body_area.width.saturating_sub(4).max(10) as usize;
490        let preview_width = list_area.width.saturating_sub(12).max(8) as usize;
491        self.message_keys.clear();
492
493        if self.markdown_width != width {
494            self.markdown_width = width;
495            self.markdown_cache.clear();
496            self.body_cache = None;
497            self.body_cache_number = None;
498        }
499
500        if let Some(err) = &self.error {
501            items.push(ListItem::new(line![Span::styled(
502                err.clone(),
503                Style::new().fg(Color::Red)
504            )]));
505        }
506
507        let Some(seed) = &self.current else {
508            items.push(ListItem::new(line![Span::styled(
509                "Press Enter on an issue to view the conversation.".to_string(),
510                Style::new().dim()
511            )]));
512            self.list_state.clear_selection();
513            return items;
514        };
515
516        if let Some(body) = seed
517            .body
518            .as_ref()
519            .map(|b| b.as_ref())
520            .filter(|b| !b.trim().is_empty())
521        {
522            if self.body_cache_number != Some(seed.number) {
523                self.body_cache_number = Some(seed.number);
524                self.body_cache = None;
525            }
526            let body_lines = self
527                .body_cache
528                .get_or_insert_with(|| render_markdown(body, width, 2));
529            items.push(build_comment_preview_item(
530                seed.author.as_ref(),
531                seed.created_at.as_ref(),
532                &body_lines.lines,
533                preview_width,
534                seed.author.as_ref() == self.current_user,
535                None,
536            ));
537            self.message_keys.push(MessageKey::IssueBody(seed.number));
538        }
539
540        if self.cache_number == Some(seed.number) {
541            trace!(
542                "Rendering {} comments for #{}",
543                self.cache_comments.len(),
544                seed.number
545            );
546            let mut merged = self
547                .cache_comments
548                .iter()
549                .map(|comment| (comment.created_ts, MessageKey::Comment(comment.id)))
550                .collect::<Vec<_>>();
551
552            if self.show_timeline && self.timeline_cache_number == Some(seed.number) {
553                merged.extend(
554                    self.cache_timeline
555                        .iter()
556                        .map(|entry| (entry.created_ts, MessageKey::Timeline(entry.id))),
557                );
558            }
559            merged.sort_by_key(|(created_ts, _)| *created_ts);
560
561            for (_, key) in merged {
562                match key {
563                    MessageKey::Comment(comment_id) => {
564                        if let Some(comment) =
565                            self.cache_comments.iter().find(|c| c.id == comment_id)
566                        {
567                            let body_lines =
568                                self.markdown_cache.entry(comment.id).or_insert_with(|| {
569                                    render_markdown(comment.body.as_ref(), width, 2)
570                                });
571                            items.push(build_comment_preview_item(
572                                comment.author.as_ref(),
573                                comment.created_at.as_ref(),
574                                &body_lines.lines,
575                                preview_width,
576                                comment.author.as_ref() == self.current_user,
577                                comment.reactions.as_deref(),
578                            ));
579                            self.message_keys.push(MessageKey::Comment(comment.id));
580                        }
581                    }
582                    MessageKey::Timeline(event_id) => {
583                        if let Some(entry) = self.cache_timeline.iter().find(|e| e.id == event_id) {
584                            items.push(build_timeline_item(entry, preview_width));
585                            self.message_keys.push(MessageKey::Timeline(entry.id));
586                        }
587                    }
588                    MessageKey::IssueBody(_) => {}
589                }
590            }
591        }
592
593        if items.is_empty() {
594            self.list_state.clear_selection();
595        } else {
596            let selected = self.list_state.selected_checked().unwrap_or(0);
597            let clamped = selected.min(items.len() - 1);
598            let _ = self.list_state.select(Some(clamped));
599        }
600
601        items
602    }
603
604    fn render_body(&mut self, body_area: Rect, buf: &mut Buffer) {
605        let selected_body = self.selected_body_render().cloned();
606        let selected_timeline = self.selected_timeline().cloned();
607        let body_lines: Vec<Line<'static>> = if let Some(entry) = selected_timeline.as_ref() {
608            build_timeline_body_lines(entry)
609        } else {
610            selected_body
611                .as_ref()
612                .map(|v| v.lines.clone())
613                .unwrap_or_else(|| {
614                    vec![Line::from(vec![Span::styled(
615                        "Select a message to view full content.".to_string(),
616                        Style::new().dim(),
617                    )])]
618                })
619        };
620
621        let body = Paragraph::new(body_lines)
622            .block(
623                Block::bordered()
624                    .border_type(ratatui::widgets::BorderType::Rounded)
625                    .border_style(get_border_style(&self.body_paragraph_state))
626                    .title(if self.screen == MainScreen::DetailsFullscreen {
627                        "Message Body (PageUp/PageDown/Home/End | f/Esc: exit fullscreen)"
628                    } else {
629                        "Message Body (PageUp/PageDown/Home/End)"
630                    }),
631            )
632            .focus_style(Style::default())
633            .hide_focus(true);
634
635        body.render(body_area, buf, &mut self.body_paragraph_state);
636
637        if let Some(render) = selected_body.as_ref() {
638            self.render_body_links(body_area, buf, render);
639        }
640    }
641
642    fn selected_body_render(&self) -> Option<&MarkdownRender> {
643        let selected = self.list_state.selected_checked()?;
644        let key = self.message_keys.get(selected)?;
645        match key {
646            MessageKey::IssueBody(number) => {
647                if self.body_cache_number == Some(*number) {
648                    self.body_cache.as_ref()
649                } else {
650                    None
651                }
652            }
653            MessageKey::Comment(id) => self.markdown_cache.get(id),
654            MessageKey::Timeline(_) => None,
655        }
656    }
657
658    fn selected_timeline(&self) -> Option<&TimelineEventView> {
659        let selected = self.list_state.selected_checked()?;
660        let key = self.message_keys.get(selected)?;
661        match key {
662            MessageKey::Timeline(id) => self.cache_timeline.iter().find(|entry| entry.id == *id),
663            _ => None,
664        }
665    }
666
667    fn render_body_links(&self, body_area: Rect, buf: &mut Buffer, render: &MarkdownRender) {
668        if render.links.is_empty() {
669            return;
670        }
671
672        let inner = Block::bordered()
673            .border_type(ratatui::widgets::BorderType::Rounded)
674            .inner(body_area);
675        if inner.width == 0 || inner.height == 0 {
676            return;
677        }
678
679        let line_offset = self.body_paragraph_state.line_offset();
680        for link in &render.links {
681            let start = link.label.len() - link.label.trim_start_matches(char::is_whitespace).len();
682            let end = link.label.trim_end_matches(char::is_whitespace).len();
683            let trimmed_label = if start < end {
684                &link.label[start..end]
685            } else {
686                continue;
687            };
688            let leading_ws_width = display_width(&link.label[..start]);
689            let link_col = link.col + leading_ws_width;
690            let link_width = display_width(trimmed_label);
691            if link_width == 0 {
692                continue;
693            }
694
695            if link.line < line_offset {
696                continue;
697            }
698
699            let local_y = link.line - line_offset;
700            if local_y >= inner.height as usize || link_col >= inner.width as usize {
701                continue;
702            }
703
704            let available = (inner.width as usize).saturating_sub(link_col);
705            if available == 0 {
706                continue;
707            }
708
709            let link_area = Rect {
710                x: inner.x + link_col as u16,
711                y: inner.y + local_y as u16,
712                width: (available.min(link_width)) as u16,
713                height: 1,
714            };
715            Link::new(trimmed_label, link.url.as_str())
716                .style(
717                    Style::new()
718                        .fg(Color::Blue)
719                        .add_modifier(Modifier::UNDERLINED),
720                )
721                .render(link_area, buf);
722        }
723    }
724
725    fn selected_comment_id(&self) -> Option<u64> {
726        let selected = self.list_state.selected_checked()?;
727        match self.message_keys.get(selected)? {
728            MessageKey::Comment(id) => Some(*id),
729            MessageKey::IssueBody(_) => None,
730            MessageKey::Timeline(_) => None,
731        }
732    }
733
734    fn selected_comment(&self) -> Option<&CommentView> {
735        let id = self.selected_comment_id()?;
736        self.cache_comments.iter().find(|c| c.id == id)
737    }
738
739    async fn open_external_editor_for_comment(
740        &mut self,
741        issue_number: u64,
742        comment_id: u64,
743        initial_body: String,
744    ) {
745        let Some(action_tx) = self.action_tx.clone() else {
746            return;
747        };
748        if action_tx
749            .send(Action::EditorModeChanged(true))
750            .await
751            .is_err()
752        {
753            return;
754        }
755
756        tokio::spawn(async move {
757            let result = tokio::task::spawn_blocking(move || {
758                ratatui::restore();
759                let edited = edit::edit(&initial_body).map_err(|err| err.to_string());
760                let _ = ratatui::init();
761                edited
762            })
763            .await
764            .map_err(|err| err.to_string())
765            .and_then(|edited| edited.map_err(|err| err.replace('\n', " ")));
766
767            let _ = action_tx.send(Action::EditorModeChanged(false)).await;
768            let _ = action_tx
769                .send(Action::IssueCommentEditFinished {
770                    issue_number,
771                    comment_id,
772                    result,
773                })
774                .await;
775            let _ = action_tx.send(Action::ForceRender).await;
776        });
777    }
778
779    async fn patch_comment(&mut self, issue_number: u64, comment_id: u64, body: String) {
780        let Some(action_tx) = self.action_tx.clone() else {
781            return;
782        };
783        let owner = self.owner.clone();
784        let repo = self.repo.clone();
785
786        tokio::spawn(async move {
787            let Some(client) = GITHUB_CLIENT.get() else {
788                let _ = action_tx
789                    .send(Action::IssueCommentEditFinished {
790                        issue_number,
791                        comment_id,
792                        result: Err("GitHub client not initialized.".to_string()),
793                    })
794                    .await;
795                return;
796            };
797
798            let handler = client.inner().issues(owner, repo);
799            match handler.update_comment(CommentId(comment_id), body).await {
800                Ok(comment) => {
801                    let _ = action_tx
802                        .send(Action::IssueCommentPatched {
803                            issue_number,
804                            comment: CommentView::from_api(comment),
805                        })
806                        .await;
807                }
808                Err(err) => {
809                    let _ = action_tx
810                        .send(Action::IssueCommentEditFinished {
811                            issue_number,
812                            comment_id,
813                            result: Err(err.to_string().replace('\n', " ")),
814                        })
815                        .await;
816                }
817            }
818        });
819    }
820
821    fn reaction_mode_prompt(&self) -> Option<String> {
822        let mode = self.reaction_mode.as_ref()?;
823        match mode {
824            ReactionMode::Add { selected, .. } => Some(format!(
825                "Add reaction: {}",
826                format_reaction_picker(*selected, &reaction_add_options())
827            )),
828            ReactionMode::Remove {
829                selected, options, ..
830            } => Some(format!(
831                "Remove reaction: {}",
832                format_reaction_picker(*selected, options)
833            )),
834        }
835    }
836
837    fn open_close_popup(&mut self) {
838        let Some(seed) = &self.current else {
839            self.close_error = Some("No issue selected.".to_string());
840            return;
841        };
842        self.close_error = None;
843        self.close_popup = Some(IssueClosePopupState::new(seed.number));
844    }
845
846    fn render_close_popup(&mut self, area: Rect, buf: &mut Buffer) {
847        let Some(popup) = self.close_popup.as_mut() else {
848            return;
849        };
850        render_issue_close_popup(popup, area, buf);
851    }
852
853    async fn submit_close_popup(&mut self) {
854        let Some(popup) = self.close_popup.as_mut() else {
855            return;
856        };
857        if popup.loading {
858            return;
859        }
860        let reason = popup.selected_reason();
861        let number = popup.issue_number;
862        popup.loading = true;
863        popup.error = None;
864
865        let Some(action_tx) = self.action_tx.clone() else {
866            popup.loading = false;
867            popup.error = Some("Action channel unavailable.".to_string());
868            return;
869        };
870        let owner = self.owner.clone();
871        let repo = self.repo.clone();
872        tokio::spawn(async move {
873            let Some(client) = GITHUB_CLIENT.get() else {
874                let _ = action_tx
875                    .send(Action::IssueCloseError {
876                        number,
877                        message: "GitHub client not initialized.".to_string(),
878                    })
879                    .await;
880                return;
881            };
882            let issues = client.inner().issues(owner, repo);
883            match issues
884                .update(number)
885                .state(IssueState::Closed)
886                .state_reason(reason.to_octocrab())
887                .send()
888                .await
889            {
890                Ok(issue) => {
891                    let _ = action_tx
892                        .send(Action::IssueCloseSuccess {
893                            issue: Box::new(issue),
894                        })
895                        .await;
896                }
897                Err(err) => {
898                    let _ = action_tx
899                        .send(Action::IssueCloseError {
900                            number,
901                            message: err.to_string().replace('\n', " "),
902                        })
903                        .await;
904                }
905            }
906        });
907    }
908
909    async fn handle_close_popup_event(&mut self, event: &event::Event) -> bool {
910        let Some(popup) = self.close_popup.as_mut() else {
911            return false;
912        };
913        if popup.loading {
914            if matches!(event, ct_event!(keycode press Esc)) {
915                popup.loading = false;
916            }
917            return true;
918        }
919        if matches!(event, ct_event!(keycode press Esc)) {
920            self.close_popup = None;
921            return true;
922        }
923        if matches!(event, ct_event!(keycode press Up)) {
924            popup.select_prev_reason();
925            return true;
926        }
927        if matches!(event, ct_event!(keycode press Down)) {
928            popup.select_next_reason();
929            return true;
930        }
931        if matches!(event, ct_event!(keycode press Enter)) {
932            self.submit_close_popup().await;
933            return true;
934        }
935        true
936    }
937
938    fn start_add_reaction_mode(&mut self) {
939        let Some(comment_id) = self.selected_comment_id() else {
940            self.reaction_error = Some("Select a comment to add a reaction.".to_string());
941            return;
942        };
943        self.reaction_error = None;
944        self.reaction_mode = Some(ReactionMode::Add {
945            comment_id,
946            selected: 0,
947        });
948    }
949
950    fn start_remove_reaction_mode(&mut self) {
951        let Some(comment) = self.selected_comment() else {
952            self.reaction_error = Some("Select a comment to remove a reaction.".to_string());
953            return;
954        };
955        let comment_id = comment.id;
956        let mut options = comment.my_reactions.as_ref().cloned().unwrap_or_default();
957
958        options.sort_by_key(reaction_order);
959        options.dedup();
960        if options.is_empty() {
961            self.reaction_error = Some("No reactions available to remove.".to_string());
962            return;
963        }
964        self.reaction_error = None;
965        self.reaction_mode = Some(ReactionMode::Remove {
966            comment_id,
967            selected: 0,
968            options,
969        });
970    }
971
972    async fn handle_reaction_mode_event(&mut self, event: &event::Event) -> bool {
973        let Some(mode) = &mut self.reaction_mode else {
974            return false;
975        };
976
977        let mut submit: Option<(u64, ReactionContent, bool)> = None;
978        match event {
979            ct_event!(keycode press Esc) => {
980                self.reaction_mode = None;
981                return true;
982            }
983            ct_event!(keycode press Up) => match mode {
984                ReactionMode::Add { selected, .. } => {
985                    let len = reaction_add_options().len();
986                    if len > 0 {
987                        *selected = if *selected == 0 {
988                            len - 1
989                        } else {
990                            *selected - 1
991                        };
992                    }
993                    return true;
994                }
995                ReactionMode::Remove {
996                    selected, options, ..
997                } => {
998                    let len = options.len();
999                    if len > 0 {
1000                        *selected = if *selected == 0 {
1001                            len - 1
1002                        } else {
1003                            *selected - 1
1004                        };
1005                    }
1006                    return true;
1007                }
1008            },
1009            ct_event!(keycode press Down) => match mode {
1010                ReactionMode::Add { selected, .. } => {
1011                    let len = reaction_add_options().len();
1012                    if len > 0 {
1013                        *selected = (*selected + 1) % len;
1014                    }
1015                    return true;
1016                }
1017                ReactionMode::Remove {
1018                    selected, options, ..
1019                } => {
1020                    let len = options.len();
1021                    if len > 0 {
1022                        *selected = (*selected + 1) % len;
1023                    }
1024                    return true;
1025                }
1026            },
1027            ct_event!(keycode press Enter) => match mode {
1028                ReactionMode::Add {
1029                    comment_id,
1030                    selected,
1031                } => {
1032                    let options = reaction_add_options();
1033                    if let Some(content) = options.get(*selected).cloned() {
1034                        submit = Some((*comment_id, content, true));
1035                    }
1036                }
1037                ReactionMode::Remove {
1038                    comment_id,
1039                    selected,
1040                    options,
1041                } => {
1042                    if let Some(content) = options.get(*selected).cloned() {
1043                        submit = Some((*comment_id, content, false));
1044                    }
1045                }
1046            },
1047            _ => return false,
1048        }
1049
1050        if let Some((comment_id, content, add)) = submit {
1051            self.reaction_mode = None;
1052            self.reaction_error = None;
1053            if add {
1054                self.add_reaction(comment_id, content).await;
1055            } else {
1056                self.remove_reaction(comment_id, content).await;
1057            }
1058            return true;
1059        }
1060        true
1061    }
1062
1063    fn is_loading_current(&self) -> bool {
1064        self.current.as_ref().is_some_and(|seed| {
1065            self.loading.contains(&seed.number)
1066                || (self.show_timeline && self.timeline_loading.contains(&seed.number))
1067        })
1068    }
1069
1070    fn has_timeline_for(&self, number: u64) -> bool {
1071        self.timeline_cache_number == Some(number)
1072    }
1073
1074    async fn add_reaction(&mut self, comment_id: u64, content: ReactionContent) {
1075        let Some(action_tx) = self.action_tx.clone() else {
1076            return;
1077        };
1078        let owner = self.owner.clone();
1079        let repo = self.repo.clone();
1080        let current_user = self.current_user.clone();
1081        tokio::spawn(async move {
1082            let Some(client) = GITHUB_CLIENT.get() else {
1083                let _ = action_tx
1084                    .send(Action::IssueReactionEditError {
1085                        comment_id,
1086                        message: "GitHub client not initialized.".to_string(),
1087                    })
1088                    .await;
1089                return;
1090            };
1091            let handler = client.inner().issues(owner, repo);
1092            if let Err(err) = handler.create_comment_reaction(comment_id, content).await {
1093                let _ = action_tx
1094                    .send(Action::IssueReactionEditError {
1095                        comment_id,
1096                        message: err.to_string().replace('\n', " "),
1097                    })
1098                    .await;
1099                return;
1100            }
1101
1102            match handler.list_comment_reactions(comment_id).send().await {
1103                Ok(mut page) => {
1104                    let (counts, mine) =
1105                        to_reaction_snapshot(std::mem::take(&mut page.items), &current_user);
1106                    let mut reactions = HashMap::new();
1107                    let mut own_reactions = HashMap::new();
1108                    reactions.insert(comment_id, counts);
1109                    own_reactions.insert(comment_id, mine);
1110                    let _ = action_tx
1111                        .send(Action::IssueReactionsLoaded {
1112                            reactions,
1113                            own_reactions,
1114                        })
1115                        .await;
1116                }
1117                Err(err) => {
1118                    let _ = action_tx
1119                        .send(Action::IssueReactionEditError {
1120                            comment_id,
1121                            message: err.to_string().replace('\n', " "),
1122                        })
1123                        .await;
1124                }
1125            }
1126        });
1127    }
1128
1129    async fn remove_reaction(&mut self, comment_id: u64, content: ReactionContent) {
1130        let Some(action_tx) = self.action_tx.clone() else {
1131            return;
1132        };
1133        let owner = self.owner.clone();
1134        let repo = self.repo.clone();
1135        let current_user = self.current_user.clone();
1136        tokio::spawn(async move {
1137            let Some(client) = GITHUB_CLIENT.get() else {
1138                let _ = action_tx
1139                    .send(Action::IssueReactionEditError {
1140                        comment_id,
1141                        message: "GitHub client not initialized.".to_string(),
1142                    })
1143                    .await;
1144                return;
1145            };
1146            let handler = client.inner().issues(owner, repo);
1147            match handler.list_comment_reactions(comment_id).send().await {
1148                Ok(mut page) => {
1149                    let mut items = std::mem::take(&mut page.items);
1150                    let to_delete = items
1151                        .iter()
1152                        .find(|reaction| {
1153                            reaction.content == content
1154                                && reaction.user.login.eq_ignore_ascii_case(&current_user)
1155                        })
1156                        .map(|reaction| reaction.id);
1157
1158                    let Some(reaction_id) = to_delete else {
1159                        let _ = action_tx
1160                            .send(Action::IssueReactionEditError {
1161                                comment_id,
1162                                message: "No matching reaction from current user.".to_string(),
1163                            })
1164                            .await;
1165                        return;
1166                    };
1167
1168                    if let Err(err) = handler
1169                        .delete_comment_reaction(comment_id, reaction_id)
1170                        .await
1171                    {
1172                        let _ = action_tx
1173                            .send(Action::IssueReactionEditError {
1174                                comment_id,
1175                                message: err.to_string().replace('\n', " "),
1176                            })
1177                            .await;
1178                        return;
1179                    }
1180
1181                    let mut removed = false;
1182                    let (counts, mine) = to_reaction_snapshot(
1183                        items.drain(..).filter_map(|reaction| {
1184                            if !removed
1185                                && reaction.content == content
1186                                && reaction.user.login.eq_ignore_ascii_case(&current_user)
1187                            {
1188                                removed = true;
1189                                None
1190                            } else {
1191                                Some(reaction)
1192                            }
1193                        }),
1194                        &current_user,
1195                    );
1196                    let mut reactions = HashMap::new();
1197                    let mut own_reactions = HashMap::new();
1198                    reactions.insert(comment_id, counts);
1199                    own_reactions.insert(comment_id, mine);
1200                    let _ = action_tx
1201                        .send(Action::IssueReactionsLoaded {
1202                            reactions,
1203                            own_reactions,
1204                        })
1205                        .await;
1206                }
1207                Err(err) => {
1208                    let _ = action_tx
1209                        .send(Action::IssueReactionEditError {
1210                            comment_id,
1211                            message: err.to_string().replace('\n', " "),
1212                        })
1213                        .await;
1214                }
1215            }
1216        });
1217    }
1218
1219    async fn fetch_comments(&mut self, number: u64) {
1220        if self.loading.contains(&number) {
1221            return;
1222        }
1223        let Some(action_tx) = self.action_tx.clone() else {
1224            return;
1225        };
1226        let owner = self.owner.clone();
1227        let repo = self.repo.clone();
1228        let current_user = self.current_user.clone();
1229        self.loading.insert(number);
1230        self.error = None;
1231
1232        tokio::spawn(async move {
1233            let Some(client) = GITHUB_CLIENT.get() else {
1234                let _ = action_tx
1235                    .send(Action::IssueCommentsError {
1236                        number,
1237                        message: "GitHub client not initialized.".to_string(),
1238                    })
1239                    .await;
1240                return;
1241            };
1242            let handler = client.inner().issues(owner, repo);
1243            let page = handler
1244                .list_comments(number)
1245                .per_page(100u8)
1246                .page(1u32)
1247                .send()
1248                .await;
1249
1250            match page {
1251                Ok(mut p) => {
1252                    let comments = std::mem::take(&mut p.items);
1253                    let comment_ids = comments.iter().map(|c| c.id.0).collect::<Vec<_>>();
1254                    let comments: Vec<CommentView> =
1255                        comments.into_iter().map(CommentView::from_api).collect();
1256                    trace!("Loaded {} comments for issue {}", comments.len(), number);
1257                    let _ = action_tx
1258                        .send(Action::IssueCommentsLoaded { number, comments })
1259                        .await;
1260                    let refer = &handler;
1261                    let current_user = current_user.clone();
1262                    let reaction_snapshots = stream::iter(comment_ids)
1263                        .filter_map(|id| {
1264                            let current_user = current_user.clone();
1265                            async move {
1266                                let reactions = refer.list_comment_reactions(id).send().await;
1267                                let mut page = reactions.ok()?;
1268                                Some((
1269                                    id,
1270                                    to_reaction_snapshot(
1271                                        std::mem::take(&mut page.items),
1272                                        &current_user,
1273                                    ),
1274                                ))
1275                            }
1276                        })
1277                        .collect::<HashMap<_, _>>()
1278                        .await;
1279                    let mut reactions = HashMap::with_capacity(reaction_snapshots.len());
1280                    let mut own_reactions = HashMap::with_capacity(reaction_snapshots.len());
1281                    for (id, (counts, mine)) in reaction_snapshots {
1282                        reactions.insert(id, counts);
1283                        own_reactions.insert(id, mine);
1284                    }
1285                    let _ = action_tx
1286                        .send(Action::IssueReactionsLoaded {
1287                            reactions,
1288                            own_reactions,
1289                        })
1290                        .await;
1291                }
1292                Err(err) => {
1293                    let _ = action_tx
1294                        .send(Action::IssueCommentsError {
1295                            number,
1296                            message: err.to_string().replace('\n', " "),
1297                        })
1298                        .await;
1299                }
1300            }
1301        });
1302    }
1303
1304    async fn fetch_timeline(&mut self, number: u64) {
1305        if self.timeline_loading.contains(&number) {
1306            return;
1307        }
1308        let Some(action_tx) = self.action_tx.clone() else {
1309            return;
1310        };
1311        let owner = self.owner.clone();
1312        let repo = self.repo.clone();
1313        self.timeline_loading.insert(number);
1314        self.timeline_error = None;
1315
1316        tokio::spawn(async move {
1317            let Some(client) = GITHUB_CLIENT.get() else {
1318                let _ = action_tx
1319                    .send(Action::IssueTimelineError {
1320                        number,
1321                        message: "GitHub client not initialized.".to_string(),
1322                    })
1323                    .await;
1324                return;
1325            };
1326            let handler = client.inner().issues(owner, repo);
1327            match handler
1328                .list_timeline_events(number)
1329                .per_page(100u8)
1330                .page(1u32)
1331                .send()
1332                .await
1333            {
1334                Ok(mut page) => {
1335                    let events = std::mem::take(&mut page.items)
1336                        .into_iter()
1337                        .enumerate()
1338                        .filter_map(|(idx, event)| {
1339                            TimelineEventView::from_api(event, (number << 32) | idx as u64)
1340                        })
1341                        .collect::<Vec<_>>();
1342                    let _ = action_tx
1343                        .send(Action::IssueTimelineLoaded { number, events })
1344                        .await;
1345                }
1346                Err(err) => {
1347                    let _ = action_tx
1348                        .send(Action::IssueTimelineError {
1349                            number,
1350                            message: err.to_string().replace('\n', " "),
1351                        })
1352                        .await;
1353                }
1354            }
1355        });
1356    }
1357
1358    async fn send_comment(&mut self, number: u64, body: String) {
1359        let Some(action_tx) = self.action_tx.clone() else {
1360            return;
1361        };
1362        let owner = self.owner.clone();
1363        let repo = self.repo.clone();
1364        self.posting = true;
1365        self.post_error = None;
1366
1367        tokio::spawn(async move {
1368            let Some(client) = GITHUB_CLIENT.get() else {
1369                let _ = action_tx
1370                    .send(Action::IssueCommentPostError {
1371                        number,
1372                        message: "GitHub client not initialized.".to_string(),
1373                    })
1374                    .await;
1375                return;
1376            };
1377            let handler = client.inner().issues(owner, repo);
1378            match handler.create_comment(number, body).await {
1379                Ok(comment) => {
1380                    let _ = action_tx
1381                        .send(Action::IssueCommentPosted {
1382                            number,
1383                            comment: CommentView::from_api(comment),
1384                        })
1385                        .await;
1386                    let _ = action_tx
1387                        .send(toast_action("Comment Sent!", ToastType::Success))
1388                        .await;
1389                }
1390                Err(err) => {
1391                    let _ = action_tx
1392                        .send(Action::IssueCommentPostError {
1393                            number,
1394                            message: err.to_string().replace('\n', " "),
1395                        })
1396                        .await;
1397                    let _ = action_tx
1398                        .send(toast_action("Failed to send comment", ToastType::Error))
1399                        .await;
1400                }
1401            }
1402        });
1403    }
1404}
1405
1406#[async_trait(?Send)]
1407impl Component for IssueConversation {
1408    fn render(&mut self, area: Layout, buf: &mut Buffer) {
1409        self.render(area, buf);
1410    }
1411
1412    fn register_action_tx(&mut self, action_tx: tokio::sync::mpsc::Sender<Action>) {
1413        self.action_tx = Some(action_tx);
1414    }
1415
1416    async fn handle_event(&mut self, event: Action) -> Result<(), AppError> {
1417        match event {
1418            Action::AppEvent(ref event) => {
1419                if !self.in_details_mode() {
1420                    return Ok(());
1421                }
1422                if self.screen == MainScreen::DetailsFullscreen
1423                    && matches!(
1424                        event,
1425                        ct_event!(key press 'f') | ct_event!(keycode press Esc)
1426                    )
1427                {
1428                    if let Some(tx) = self.action_tx.clone() {
1429                        let _ = tx
1430                            .send(Action::ChangeIssueScreen(MainScreen::Details))
1431                            .await;
1432                    }
1433                    return Ok(());
1434                }
1435                if self.handle_close_popup_event(event).await {
1436                    return Ok(());
1437                }
1438                if self.handle_reaction_mode_event(event).await {
1439                    return Ok(());
1440                }
1441
1442                match event {
1443                    event::Event::Key(key)
1444                        if key.code == event::KeyCode::Char('t')
1445                            && key.modifiers == event::KeyModifiers::NONE
1446                            && (self.list_state.is_focused()
1447                                || self.body_paragraph_state.is_focused()) =>
1448                    {
1449                        self.show_timeline = !self.show_timeline;
1450                        self.timeline_error = None;
1451                        if self.show_timeline
1452                            && let Some(seed) = self.current.as_ref()
1453                            && !self.has_timeline_for(seed.number)
1454                        {
1455                            self.fetch_timeline(seed.number).await;
1456                        }
1457                        if let Some(tx) = self.action_tx.clone() {
1458                            let _ = tx.send(Action::ForceRender).await;
1459                        }
1460                        return Ok(());
1461                    }
1462                    ct_event!(key press 'l')
1463                        if self.body_paragraph_state.is_focused()
1464                            || self.list_state.is_focused() =>
1465                    {
1466                        let Some(current) = self.current.as_ref() else {
1467                            return Ok(());
1468                        };
1469                        let Some(selected_idx) = self.list_state.selected_checked() else {
1470                            return Ok(());
1471                        };
1472
1473                        let Some(selected) = self.message_keys.get(selected_idx) else {
1474                            return Ok(());
1475                        };
1476
1477                        match selected {
1478                            MessageKey::IssueBody(i) => {
1479                                assert_eq!(*i, current.number);
1480                                let link = format!(
1481                                    "https://github.com/{}/{}/issues/{}",
1482                                    self.owner, self.repo, i
1483                                );
1484                                cli_clipboard::set_contents(link)
1485                                    .map_err(|_| anyhow!("Error copying to clipboard"))?;
1486                            }
1487                            MessageKey::Comment(id) => {
1488                                let link = format!(
1489                                    "https://github.com/{}/{}/issues/{}#issuecomment-{}",
1490                                    self.owner, self.repo, current.number, id
1491                                );
1492
1493                                cli_clipboard::set_contents(link)
1494                                    .map_err(|_| anyhow!("Error copying to clipboard"))?;
1495                            }
1496                            _ => {
1497                                return Ok(());
1498                            }
1499                        }
1500                        if let Some(tx) = self.action_tx.clone() {
1501                            tx.send(Action::ToastAction(ratatui_toaster::ToastMessage::Show {
1502                                message: "Copied Link".to_string(),
1503                                toast_type: ToastType::Success,
1504
1505                                position: ToastPosition::TopRight,
1506                            }))
1507                            .await?;
1508                            tx.send(Action::ForceRender).await?;
1509                        }
1510                    }
1511                    event::Event::Key(key)
1512                        if key.code == event::KeyCode::Char('f')
1513                            && key.modifiers == event::KeyModifiers::NONE
1514                            && self.screen == MainScreen::Details
1515                            && self.body_paragraph_state.is_focused() =>
1516                    {
1517                        if let Some(tx) = self.action_tx.clone() {
1518                            let _ = tx
1519                                .send(Action::ChangeIssueScreen(MainScreen::DetailsFullscreen))
1520                                .await;
1521                        }
1522                        return Ok(());
1523                    }
1524                    event::Event::Key(key)
1525                        if key.code == event::KeyCode::Char('e')
1526                            && key.modifiers == event::KeyModifiers::NONE
1527                            && (self.list_state.is_focused()
1528                                || self.body_paragraph_state.is_focused()) =>
1529                    {
1530                        let seed = self.current.as_ref().ok_or_else(|| {
1531                            AppError::Other(anyhow!("no issue selected for comment editing"))
1532                        })?;
1533                        let comment = self
1534                            .selected_comment()
1535                            .ok_or_else(|| AppError::Other(anyhow!("select a comment to edit")))?;
1536                        self.open_external_editor_for_comment(
1537                            seed.number,
1538                            comment.id,
1539                            comment.body.to_string(),
1540                        )
1541                        .await;
1542                        return Ok(());
1543                    }
1544                    event::Event::Key(key)
1545                        if key.code == event::KeyCode::Char('r')
1546                            && key.modifiers == event::KeyModifiers::NONE
1547                            && self.list_state.is_focused() =>
1548                    {
1549                        self.start_add_reaction_mode();
1550                        return Ok(());
1551                    }
1552                    event::Event::Key(key)
1553                        if key.code == event::KeyCode::Char('R')
1554                            && self.list_state.is_focused() =>
1555                    {
1556                        self.start_remove_reaction_mode();
1557                        return Ok(());
1558                    }
1559                    event::Event::Key(key)
1560                        if key.code == event::KeyCode::Char('C')
1561                            && (self.list_state.is_focused()
1562                                || self.body_paragraph_state.is_focused()) =>
1563                    {
1564                        self.open_close_popup();
1565                        return Ok(());
1566                    }
1567                    ct_event!(keycode press Tab) | ct_event!(keycode press BackTab)
1568                        if self.input_state.is_focused() =>
1569                    {
1570                        let action_tx = self.action_tx.as_ref().ok_or_else(|| {
1571                            AppError::Other(anyhow!(
1572                                "issue conversation action channel unavailable"
1573                            ))
1574                        })?;
1575                        action_tx.send(Action::ForceFocusChange).await?;
1576                    }
1577                    ct_event!(keycode press Esc) if self.body_paragraph_state.is_focused() => {
1578                        let action_tx = self.action_tx.as_ref().ok_or_else(|| {
1579                            AppError::Other(anyhow!(
1580                                "issue conversation action channel unavailable"
1581                            ))
1582                        })?;
1583                        action_tx.send(Action::ForceFocusChangeRev).await?;
1584                    }
1585                    ct_event!(keycode press Esc) if !self.body_paragraph_state.is_focused() => {
1586                        if let Some(tx) = self.action_tx.clone() {
1587                            let _ = tx.send(Action::ChangeIssueScreen(MainScreen::List)).await;
1588                        }
1589                        return Ok(());
1590                    }
1591                    ct_event!(key press CONTROL-'p') => {
1592                        self.textbox_state.toggle();
1593                        match self.textbox_state {
1594                            InputState::Input => {
1595                                self.input_state.focus.set(true);
1596                                self.paragraph_state.focus.set(false);
1597                            }
1598                            InputState::Preview => {
1599                                self.input_state.focus.set(false);
1600                                self.paragraph_state.focus.set(true);
1601                            }
1602                        }
1603                        if let Some(ref tx) = self.action_tx {
1604                            let _ = tx.send(Action::ForceRender).await;
1605                        }
1606                    }
1607                    ct_event!(keycode press Enter) if self.list_state.is_focused() => {
1608                        let action_tx = self.action_tx.as_ref().ok_or_else(|| {
1609                            AppError::Other(anyhow!(
1610                                "issue conversation action channel unavailable"
1611                            ))
1612                        })?;
1613                        action_tx.send(Action::ForceFocusChange).await?;
1614                    }
1615                    ct_event!(keycode press CONTROL-Enter) | ct_event!(keycode press ALT-Enter) => {
1616                        let Some(seed) = &self.current else {
1617                            return Ok(());
1618                        };
1619                        let body = self.input_state.text();
1620                        let trimmed = body.trim();
1621                        if trimmed.is_empty() {
1622                            self.post_error = Some("Comment cannot be empty.".to_string());
1623                            return Ok(());
1624                        }
1625                        self.input_state.set_text("");
1626                        self.send_comment(seed.number, trimmed.to_string()).await;
1627                        return Ok(());
1628                    }
1629
1630                    ct_event!(key press '>')
1631                        if self.list_state.is_focused()
1632                            || self.body_paragraph_state.is_focused() =>
1633                    {
1634                        if let Some(comment) = self.selected_comment() {
1635                            let comment_body = comment.body.as_ref();
1636                            let quoted = comment_body
1637                                .lines()
1638                                .map(|line| format!("> {}", line.trim()))
1639                                .collect::<Vec<_>>()
1640                                .join("\n");
1641                            self.input_state.insert_str(&quoted);
1642                            self.input_state.insert_newline();
1643                            self.input_state.move_to_end(false);
1644                            self.input_state.move_to_line_end(false);
1645                            self.input_state.focus.set(true);
1646                            self.list_state.focus.set(false);
1647                        }
1648                    }
1649
1650                    event::Event::Key(key) if key.code != event::KeyCode::Tab => {
1651                        let o = self.input_state.handle(event, rat_widget::event::Regular);
1652                        let o2 = self
1653                            .paragraph_state
1654                            .handle(event, rat_widget::event::Regular);
1655                        if matches!(
1656                            event,
1657                            ct_event!(keycode press Up)
1658                                | ct_event!(keycode press Down)
1659                                | ct_event!(keycode press Left)
1660                                | ct_event!(keycode press Right)
1661                        ) {
1662                            let action_tx = self.action_tx.as_ref().ok_or_else(|| {
1663                                AppError::Other(anyhow!(
1664                                    "issue conversation action channel unavailable"
1665                                ))
1666                            })?;
1667                            action_tx.send(Action::ForceRender).await?;
1668                        }
1669                        if o == TextOutcome::TextChanged || o2 == Outcome::Changed {
1670                            trace!("Input changed, forcing re-render");
1671                            let action_tx = self.action_tx.as_ref().ok_or_else(|| {
1672                                AppError::Other(anyhow!(
1673                                    "issue conversation action channel unavailable"
1674                                ))
1675                            })?;
1676                            action_tx.send(Action::ForceRender).await?;
1677                        }
1678                    }
1679                    event::Event::Paste(p) if self.input_state.is_focused() => {
1680                        self.input_state.insert_str(p);
1681                        let action_tx = self.action_tx.as_ref().ok_or_else(|| {
1682                            AppError::Other(anyhow!(
1683                                "issue conversation action channel unavailable"
1684                            ))
1685                        })?;
1686                        action_tx.send(Action::ForceRender).await?;
1687                    }
1688                    _ => {}
1689                }
1690                self.body_paragraph_state
1691                    .handle(event, rat_widget::event::Regular);
1692                let outcome = self.list_state.handle(event, rat_widget::event::Regular);
1693                if outcome == rat_widget::event::Outcome::Changed {
1694                    self.body_paragraph_state.set_line_offset(0);
1695                }
1696            }
1697            Action::EnterIssueDetails { seed } => {
1698                let number = seed.number;
1699                self.title = seed.title.clone();
1700                self.current = Some(seed);
1701                self.post_error = None;
1702                self.reaction_error = None;
1703                self.close_error = None;
1704                self.reaction_mode = None;
1705                self.close_popup = None;
1706                self.timeline_error = None;
1707                self.body_cache = None;
1708                self.body_cache_number = Some(number);
1709                self.body_paragraph_state.set_line_offset(0);
1710                if self.cache_number != Some(number) {
1711                    self.cache_number = None;
1712                    self.cache_comments.clear();
1713                    self.markdown_cache.clear();
1714                }
1715                if self.timeline_cache_number != Some(number) {
1716                    self.timeline_cache_number = None;
1717                    self.cache_timeline.clear();
1718                }
1719                if self.cache_number == Some(number) {
1720                    self.loading.remove(&number);
1721                    self.error = None;
1722                } else {
1723                    self.fetch_comments(number).await;
1724                }
1725                if self.show_timeline {
1726                    if self.has_timeline_for(number) {
1727                        self.timeline_loading.remove(&number);
1728                    } else {
1729                        self.fetch_timeline(number).await;
1730                    }
1731                }
1732            }
1733            Action::IssueCommentsLoaded { number, comments } => {
1734                self.loading.remove(&number);
1735                if self.current.as_ref().is_some_and(|s| s.number == number) {
1736                    self.cache_number = Some(number);
1737                    trace!("Setting {} comments for #{}", comments.len(), number);
1738                    self.cache_comments = comments;
1739                    self.markdown_cache.clear();
1740                    self.body_cache = None;
1741                    self.body_paragraph_state.set_line_offset(0);
1742                    self.error = None;
1743                    let action_tx = self.action_tx.as_ref().ok_or_else(|| {
1744                        AppError::Other(anyhow!("issue conversation action channel unavailable"))
1745                    })?;
1746                    action_tx.send(Action::ForceRender).await?;
1747                }
1748            }
1749            Action::IssueReactionsLoaded {
1750                reactions,
1751                own_reactions,
1752            } => {
1753                self.reaction_error = None;
1754                for (id, reaction_content) in reactions {
1755                    if let Some(comment) = self.cache_comments.iter_mut().find(|c| c.id == id) {
1756                        comment.reactions = Some(reaction_content);
1757                        comment.my_reactions =
1758                            Some(own_reactions.get(&id).cloned().unwrap_or_else(Vec::new));
1759                    }
1760                }
1761            }
1762            Action::IssueReactionEditError {
1763                comment_id: _,
1764                message,
1765            } => {
1766                self.reaction_error = Some(message);
1767            }
1768            Action::IssueCommentPosted { number, comment } => {
1769                self.posting = false;
1770                if self.current.as_ref().is_some_and(|s| s.number == number) {
1771                    if self.cache_number == Some(number) {
1772                        self.cache_comments.push(comment);
1773                    } else {
1774                        self.cache_number = Some(number);
1775                        self.cache_comments.clear();
1776                        self.cache_comments.push(comment);
1777                        self.markdown_cache.clear();
1778                        self.body_cache = None;
1779                    }
1780                }
1781            }
1782            Action::IssueCommentsError { number, message } => {
1783                self.loading.remove(&number);
1784                if self.current.as_ref().is_some_and(|s| s.number == number) {
1785                    self.error = Some(message);
1786                }
1787            }
1788            Action::IssueTimelineLoaded { number, events } => {
1789                self.timeline_loading.remove(&number);
1790                if self.current.as_ref().is_some_and(|s| s.number == number) {
1791                    self.timeline_cache_number = Some(number);
1792                    self.cache_timeline = events;
1793                    self.timeline_error = None;
1794                    if let Some(action_tx) = self.action_tx.as_ref() {
1795                        let _ = action_tx.send(Action::ForceRender).await;
1796                    }
1797                }
1798            }
1799            Action::IssueTimelineError { number, message } => {
1800                self.timeline_loading.remove(&number);
1801                if self.current.as_ref().is_some_and(|s| s.number == number) {
1802                    self.timeline_error = Some(message);
1803                }
1804            }
1805            Action::IssueCommentPostError { number, message } => {
1806                self.posting = false;
1807                if self.current.as_ref().is_some_and(|s| s.number == number) {
1808                    self.post_error = Some(message);
1809                }
1810            }
1811            Action::IssueCommentEditFinished {
1812                issue_number,
1813                comment_id,
1814                result,
1815            } => {
1816                if self
1817                    .current
1818                    .as_ref()
1819                    .is_none_or(|seed| seed.number != issue_number)
1820                {
1821                    return Ok(());
1822                }
1823                match result {
1824                    Ok(body) => {
1825                        let Some(existing) =
1826                            self.cache_comments.iter().find(|c| c.id == comment_id)
1827                        else {
1828                            return Err(AppError::Other(anyhow!(
1829                                "selected comment is no longer available"
1830                            )));
1831                        };
1832                        if body == existing.body.as_ref() {
1833                            return Ok(());
1834                        }
1835                        let trimmed = body.trim();
1836                        if trimmed.is_empty() {
1837                            return Err(AppError::Other(anyhow!(
1838                                "comment cannot be empty after editing"
1839                            )));
1840                        }
1841                        self.patch_comment(issue_number, comment_id, trimmed.to_string())
1842                            .await;
1843                        if let Some(action_tx) = self.action_tx.as_ref() {
1844                            action_tx.send(Action::ForceRender).await?;
1845                        }
1846                    }
1847                    Err(message) => {
1848                        return Err(AppError::Other(anyhow!("comment edit failed: {message}")));
1849                    }
1850                }
1851            }
1852            Action::IssueCommentPatched {
1853                issue_number,
1854                comment,
1855            } => {
1856                if self
1857                    .current
1858                    .as_ref()
1859                    .is_some_and(|seed| seed.number == issue_number)
1860                    && let Some(existing) =
1861                        self.cache_comments.iter_mut().find(|c| c.id == comment.id)
1862                {
1863                    let reactions = existing.reactions.clone();
1864                    let my_reactions = existing.my_reactions.clone();
1865                    *existing = comment;
1866                    existing.reactions = reactions;
1867                    existing.my_reactions = my_reactions;
1868                    self.markdown_cache.remove(&existing.id);
1869                }
1870            }
1871            Action::IssueCloseSuccess { issue } => {
1872                let issue = *issue;
1873                let initiated_here = self
1874                    .close_popup
1875                    .as_ref()
1876                    .is_some_and(|popup| popup.issue_number == issue.number);
1877                if initiated_here {
1878                    self.close_popup = None;
1879                    self.close_error = None;
1880                    if let Some(action_tx) = self.action_tx.as_ref() {
1881                        let _ = action_tx
1882                            .send(Action::SelectedIssuePreview {
1883                                seed: crate::ui::components::issue_detail::IssuePreviewSeed::from_issue(
1884                                    &issue,
1885                                ),
1886                            })
1887                            .await;
1888                        let _ = action_tx.send(Action::RefreshIssueList).await;
1889                    }
1890                }
1891            }
1892            Action::IssueCloseError { number, message } => {
1893                if let Some(popup) = self.close_popup.as_mut()
1894                    && popup.issue_number == number
1895                {
1896                    popup.loading = false;
1897                    popup.error = Some(message.clone());
1898                    self.close_error = Some(message);
1899                }
1900            }
1901            Action::ChangeIssueScreen(screen) => {
1902                self.screen = screen;
1903                match screen {
1904                    MainScreen::List => {
1905                        self.input_state.focus.set(false);
1906                        self.list_state.focus.set(false);
1907                        self.reaction_mode = None;
1908                        self.close_popup = None;
1909                    }
1910                    MainScreen::Details => {}
1911                    MainScreen::DetailsFullscreen => {
1912                        self.list_state.focus.set(false);
1913                        self.input_state.focus.set(false);
1914                        self.paragraph_state.focus.set(false);
1915                        self.body_paragraph_state.focus.set(true);
1916                    }
1917                    MainScreen::CreateIssue => {
1918                        self.input_state.focus.set(false);
1919                        self.list_state.focus.set(false);
1920                        self.reaction_mode = None;
1921                        self.close_popup = None;
1922                    }
1923                }
1924            }
1925            Action::Tick => {
1926                if self.is_loading_current() {
1927                    self.throbber_state.calc_next();
1928                }
1929                if self.posting {
1930                    self.post_throbber_state.calc_next();
1931                }
1932                if let Some(popup) = self.close_popup.as_mut()
1933                    && popup.loading
1934                {
1935                    popup.throbber_state.calc_next();
1936                }
1937            }
1938            _ => {}
1939        }
1940        Ok(())
1941    }
1942
1943    fn cursor(&self) -> Option<(u16, u16)> {
1944        self.input_state.screen_cursor()
1945    }
1946
1947    fn should_render(&self) -> bool {
1948        self.in_details_mode()
1949    }
1950
1951    fn is_animating(&self) -> bool {
1952        self.in_details_mode()
1953            && (self.is_loading_current()
1954                || self.posting
1955                || self.close_popup.as_ref().is_some_and(|popup| popup.loading))
1956    }
1957
1958    fn capture_focus_event(&self, event: &crossterm::event::Event) -> bool {
1959        if !self.in_details_mode() {
1960            return false;
1961        }
1962        if self.screen == MainScreen::DetailsFullscreen {
1963            return true;
1964        }
1965        if self.close_popup.is_some() {
1966            return true;
1967        }
1968        if self.input_state.is_focused() {
1969            return true;
1970        }
1971        match event {
1972            crossterm::event::Event::Key(key) => matches!(
1973                key.code,
1974                crossterm::event::KeyCode::Tab
1975                    | crossterm::event::KeyCode::BackTab
1976                    | crossterm::event::KeyCode::Char('q')
1977            ),
1978            _ => false,
1979        }
1980    }
1981    fn set_index(&mut self, index: usize) {
1982        self.index = index;
1983    }
1984
1985    fn set_global_help(&self) {
1986        if let Some(action_tx) = &self.action_tx {
1987            let _ = action_tx.try_send(Action::SetHelp(HELP));
1988        }
1989    }
1990}
1991
1992impl HasFocus for IssueConversation {
1993    fn build(&self, builder: &mut FocusBuilder) {
1994        let tag = builder.start(self);
1995        builder.widget(&self.list_state);
1996        builder.widget(&self.body_paragraph_state);
1997        match self.textbox_state {
1998            InputState::Input => builder.widget(&self.input_state),
1999            InputState::Preview => builder.widget(&self.paragraph_state),
2000        };
2001        builder.end(tag);
2002    }
2003
2004    fn focus(&self) -> FocusFlag {
2005        self.focus.clone()
2006    }
2007
2008    fn area(&self) -> Rect {
2009        self.area
2010    }
2011
2012    fn navigable(&self) -> Navigation {
2013        if self.in_details_mode() {
2014            Navigation::Regular
2015        } else {
2016            Navigation::None
2017        }
2018    }
2019}
2020
2021fn build_comment_item(
2022    author: &str,
2023    created_at: &str,
2024    preview: &str,
2025    is_self: bool,
2026    reactions: Option<&[(ReactionContent, u64)]>,
2027) -> ListItem<'static> {
2028    let author_style = if is_self {
2029        Style::new().fg(Color::Green).add_modifier(Modifier::BOLD)
2030    } else {
2031        Style::new().fg(Color::Cyan)
2032    };
2033    let header = Line::from(vec![
2034        Span::styled(author.to_string(), author_style),
2035        Span::raw("  "),
2036        Span::styled(created_at.to_string(), Style::new()),
2037    ]);
2038    let preview_line = Line::from(vec![
2039        Span::raw("  "),
2040        Span::styled(preview.to_string(), Style::new()),
2041    ]);
2042    let mut lines = vec![header, preview_line];
2043    if let Some(reactions) = reactions
2044        && !reactions.is_empty()
2045    {
2046        lines.push(build_reactions_line(reactions));
2047    }
2048    ListItem::new(lines)
2049}
2050
2051fn build_comment_preview_item(
2052    author: &str,
2053    created_at: &str,
2054    body_lines: &[Line<'static>],
2055    preview_width: usize,
2056    is_self: bool,
2057    reactions: Option<&[(ReactionContent, u64)]>,
2058) -> ListItem<'static> {
2059    let preview = extract_preview(body_lines, preview_width);
2060    build_comment_item(author, created_at, &preview, is_self, reactions)
2061}
2062
2063fn build_timeline_item(entry: &TimelineEventView, preview_width: usize) -> ListItem<'static> {
2064    let icon_style = timeline_event_style(&entry.event).add_modifier(Modifier::DIM);
2065    let dim_style = Style::new().dim();
2066    let header = Line::from(vec![
2067        Span::raw("  "),
2068        Span::styled("|", dim_style),
2069        Span::raw(" "),
2070        Span::styled(
2071            entry.icon.to_string(),
2072            icon_style.add_modifier(Modifier::BOLD),
2073        ),
2074        Span::styled(" ", dim_style),
2075        Span::styled(entry.summary.to_string(), icon_style),
2076        Span::styled("  ", dim_style),
2077        Span::styled(entry.created_at.to_string(), dim_style),
2078    ]);
2079    let details = Line::from(vec![
2080        Span::raw("  "),
2081        Span::styled("|", dim_style),
2082        Span::raw("   "),
2083        Span::styled(
2084            truncate_preview(entry.details.as_ref(), preview_width.max(12)),
2085            dim_style,
2086        ),
2087    ]);
2088    ListItem::new(vec![header, details])
2089}
2090
2091fn build_timeline_body_lines(entry: &TimelineEventView) -> Vec<Line<'static>> {
2092    vec![
2093        Line::from(vec![
2094            Span::styled("Event: ", Style::new().dim()),
2095            Span::styled(
2096                format!("{} {}", entry.icon, entry.summary),
2097                timeline_event_style(&entry.event),
2098            ),
2099        ]),
2100        Line::from(vec![
2101            Span::styled("When: ", Style::new().dim()),
2102            Span::raw(entry.created_at.to_string()),
2103        ]),
2104        Line::from(vec![
2105            Span::styled("Details: ", Style::new().dim()),
2106            Span::styled(entry.details.to_string(), Style::new().fg(Color::Gray)),
2107        ]),
2108    ]
2109}
2110
2111fn build_reactions_line(reactions: &[(ReactionContent, u64)]) -> Line<'static> {
2112    let mut ordered = reactions.iter().collect::<Vec<_>>();
2113    ordered.sort_by_key(|(content, _)| reaction_order(content));
2114
2115    let mut spans = vec![Span::raw("  ")];
2116    for (idx, (content, count)) in ordered.into_iter().enumerate() {
2117        if idx != 0 {
2118            spans.push(Span::raw("  "));
2119        }
2120        spans.push(Span::styled(
2121            reaction_label(content).to_string(),
2122            Style::new().fg(Color::Yellow),
2123        ));
2124        spans.push(Span::raw(" "));
2125        spans.push(Span::styled(count.to_string(), Style::new().dim()));
2126    }
2127    Line::from(spans)
2128}
2129
2130fn timeline_event_meta(event: &IssueEvent) -> (&'static str, &'static str) {
2131    match event {
2132        IssueEvent::Closed => ("x", "closed the issue"),
2133        IssueEvent::Reopened => ("o", "reopened the issue"),
2134        IssueEvent::Assigned => ("@", "assigned someone"),
2135        IssueEvent::Unassigned => ("@", "unassigned someone"),
2136        IssueEvent::Labeled => ("#", "added a label"),
2137        IssueEvent::Unlabeled => ("#", "removed a label"),
2138        IssueEvent::Milestoned => ("M", "set a milestone"),
2139        IssueEvent::Demilestoned => ("M", "removed the milestone"),
2140        IssueEvent::Locked => ("!", "locked the conversation"),
2141        IssueEvent::Unlocked => ("!", "unlocked the conversation"),
2142        IssueEvent::Referenced | IssueEvent::CrossReferenced => ("=>", "referenced this issue"),
2143        IssueEvent::Renamed => ("~", "renamed the title"),
2144        IssueEvent::ReviewRequested => ("R", "requested review"),
2145        IssueEvent::ReviewRequestRemoved => ("R", "removed review request"),
2146        IssueEvent::Merged => ("+", "merged"),
2147        IssueEvent::Committed => ("*", "pushed a commit"),
2148        _ => ("*", "updated the timeline"),
2149    }
2150}
2151
2152fn timeline_event_style(event: &IssueEvent) -> Style {
2153    match event {
2154        IssueEvent::Closed | IssueEvent::Locked => Style::new().fg(Color::Red),
2155        IssueEvent::Reopened | IssueEvent::Unlocked => Style::new().fg(Color::Green),
2156        IssueEvent::Labeled | IssueEvent::Unlabeled => Style::new().fg(Color::Yellow),
2157        IssueEvent::Assigned | IssueEvent::Unassigned => Style::new().fg(Color::Cyan),
2158        IssueEvent::Merged => Style::new().fg(Color::Magenta),
2159        _ => Style::new().fg(Color::Blue),
2160    }
2161}
2162
2163fn timeline_event_details(event: &TimelineEvent) -> String {
2164    match event.event {
2165        IssueEvent::Labeled | IssueEvent::Unlabeled => {
2166            if let Some(label) = event.label.as_ref() {
2167                return format!("label: {}", label.name);
2168            }
2169        }
2170        IssueEvent::Milestoned | IssueEvent::Demilestoned => {
2171            if let Some(milestone) = event.milestone.as_ref() {
2172                return format!("milestone: {}", milestone.title);
2173            }
2174        }
2175        IssueEvent::Renamed => {
2176            if let Some(rename) = event.rename.as_ref() {
2177                return format!("title: '{}' -> '{}'", rename.from, rename.to);
2178            }
2179        }
2180        IssueEvent::Assigned | IssueEvent::Unassigned => {
2181            if let Some(assignee) = event.assignee.as_ref() {
2182                return format!("assignee: @{}", assignee.login);
2183            }
2184            if let Some(assignees) = event.assignees.as_ref()
2185                && !assignees.is_empty()
2186            {
2187                let names = assignees
2188                    .iter()
2189                    .map(|a| format!("@{}", a.login))
2190                    .collect::<Vec<_>>()
2191                    .join(", ");
2192                return format!("assignees: {}", names);
2193            }
2194        }
2195        IssueEvent::ReviewRequested | IssueEvent::ReviewRequestRemoved => {
2196            if let Some(reviewer) = event.requested_reviewer.as_ref() {
2197                return format!("reviewer: @{}", reviewer.login);
2198            }
2199        }
2200        IssueEvent::Closed
2201        | IssueEvent::Merged
2202        | IssueEvent::Referenced
2203        | IssueEvent::Committed => {
2204            if let Some(reference) = format_reference_target(event) {
2205                return reference;
2206            }
2207            if let Some(commit_id) = event.commit_id.as_ref() {
2208                let short = commit_id.chars().take(8).collect::<String>();
2209                return format!("commit {}", short);
2210            }
2211            if let Some(sha) = event.sha.as_ref() {
2212                let short = sha.chars().take(8).collect::<String>();
2213                return format!("sha {}", short);
2214            }
2215        }
2216        IssueEvent::CrossReferenced | IssueEvent::Connected | IssueEvent::Disconnected => {
2217            if let Some(reference) = format_reference_target(event) {
2218                return reference;
2219            }
2220        }
2221        _ => {}
2222    }
2223
2224    if let Some(assignee) = event.assignee.as_ref() {
2225        return format!("assignee: @{}", assignee.login);
2226    }
2227    if let Some(assignees) = event.assignees.as_ref()
2228        && !assignees.is_empty()
2229    {
2230        let names = assignees
2231            .iter()
2232            .map(|a| format!("@{}", a.login))
2233            .collect::<Vec<_>>()
2234            .join(", ");
2235        return format!("assignees: {}", names);
2236    }
2237    if let Some(commit_id) = event.commit_id.as_ref() {
2238        let short = commit_id.chars().take(8).collect::<String>();
2239        return format!("commit {}", short);
2240    }
2241    if let Some(reference) = format_reference_target(event) {
2242        return reference;
2243    }
2244    if let Some(column) = event.column_name.as_ref() {
2245        if let Some(prev) = event.previous_column_name.as_ref() {
2246            return format!("moved from '{}' to '{}'", prev, column);
2247        }
2248        return format!("project column: {}", column);
2249    }
2250    if let Some(reason) = event.lock_reason.as_ref() {
2251        return format!("lock reason: {}", reason);
2252    }
2253    if let Some(message) = event.message.as_ref()
2254        && !message.trim().is_empty()
2255    {
2256        return truncate_preview(message.trim(), 96);
2257    }
2258    if let Some(body) = event.body.as_ref()
2259        && !body.trim().is_empty()
2260    {
2261        return truncate_preview(body.trim(), 96);
2262    }
2263    format!("{:?}", event.event)
2264}
2265
2266fn format_reference_target(event: &TimelineEvent) -> Option<String> {
2267    if let Some(url) = event.pull_request_url.as_ref() {
2268        if let Some(number) = extract_trailing_number(url.as_str()) {
2269            return Some(format!("pull request #{}", number));
2270        }
2271        return Some(format!("pull request {}", url));
2272    }
2273
2274    if let Some(url) = event.issue_url.as_deref() {
2275        if let Some(number) = extract_trailing_number(url) {
2276            return Some(format!("issue #{}", number));
2277        }
2278        return Some(format!("issue {}", url));
2279    }
2280
2281    None
2282}
2283
2284fn extract_trailing_number(url: &str) -> Option<u64> {
2285    let tail = url.trim_end_matches('/').rsplit('/').next()?;
2286    tail.parse::<u64>().ok()
2287}
2288
2289fn reaction_order(content: &ReactionContent) -> usize {
2290    match content {
2291        ReactionContent::PlusOne => 0,
2292        ReactionContent::Heart => 1,
2293        ReactionContent::Hooray => 2,
2294        ReactionContent::Laugh => 3,
2295        ReactionContent::Rocket => 4,
2296        ReactionContent::Eyes => 5,
2297        ReactionContent::Confused => 6,
2298        ReactionContent::MinusOne => 7,
2299        _ => usize::MAX,
2300    }
2301}
2302
2303fn reaction_label(content: &ReactionContent) -> &'static str {
2304    match content {
2305        ReactionContent::PlusOne => "+1",
2306        ReactionContent::MinusOne => "-1",
2307        ReactionContent::Laugh => "laugh",
2308        ReactionContent::Confused => "confused",
2309        ReactionContent::Heart => "heart",
2310        ReactionContent::Hooray => "hooray",
2311        ReactionContent::Rocket => "rocket",
2312        ReactionContent::Eyes => "eyes",
2313        _ => "other",
2314    }
2315}
2316
2317fn reaction_add_options() -> [ReactionContent; 8] {
2318    [
2319        ReactionContent::PlusOne,
2320        ReactionContent::Heart,
2321        ReactionContent::Hooray,
2322        ReactionContent::Laugh,
2323        ReactionContent::Rocket,
2324        ReactionContent::Eyes,
2325        ReactionContent::Confused,
2326        ReactionContent::MinusOne,
2327    ]
2328}
2329
2330fn format_reaction_picker(selected: usize, options: &[ReactionContent]) -> String {
2331    let mut out = String::new();
2332    let mut bracket_start = None;
2333    let mut bracket_end = None;
2334    const TOTAL_WIDTH: usize = 20;
2335    for (idx, content) in options.iter().enumerate() {
2336        if idx > 0 {
2337            out.push(' ');
2338        }
2339        let label = reaction_label(content);
2340        if idx == selected {
2341            bracket_start = Some(out.len());
2342            out.push('[');
2343            out.push_str(label);
2344            bracket_end = Some(out.len());
2345            out.push(']');
2346        } else {
2347            out.push_str(label);
2348        }
2349    }
2350    if let (Some(start), Some(end)) = (bracket_start, bracket_end) {
2351        let padding = TOTAL_WIDTH.saturating_sub(end - start + 1);
2352        let left_padding = padding / 2;
2353        let left_start = start.saturating_sub(left_padding);
2354        let right_padding = padding - left_padding;
2355        let right_end = (end + right_padding).min(out.len());
2356        return out[left_start..right_end].to_string();
2357    }
2358    out
2359}
2360
2361fn to_reaction_snapshot<I>(
2362    reactions: I,
2363    current_user: &str,
2364) -> (Vec<(ReactionContent, u64)>, Vec<ReactionContent>)
2365where
2366    I: IntoIterator<Item = octocrab::models::reactions::Reaction>,
2367{
2368    let mut mine = Vec::new();
2369    let counts = reactions
2370        .into_iter()
2371        .fold(HashMap::new(), |mut acc, reaction| {
2372            if reaction.user.login.eq_ignore_ascii_case(current_user) {
2373                mine.push(reaction.content.clone());
2374            }
2375            *acc.entry(reaction.content).or_insert(0) += 1_u64;
2376            acc
2377        });
2378    mine.sort_by_key(reaction_order);
2379    mine.dedup();
2380    (counts.into_iter().collect::<Vec<_>>(), mine)
2381}
2382
2383fn extract_preview(lines: &[Line<'static>], preview_width: usize) -> String {
2384    for line in lines {
2385        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
2386        let trimmed = text.trim();
2387        if !trimmed.is_empty() {
2388            return truncate_preview(trimmed, preview_width.max(8));
2389        }
2390    }
2391    "(empty)".to_string()
2392}
2393
2394fn truncate_preview(input: &str, max_width: usize) -> String {
2395    if display_width(input) <= max_width {
2396        return input.to_string();
2397    }
2398    let mut out = String::new();
2399    for ch in input.chars() {
2400        let mut candidate = out.clone();
2401        candidate.push(ch);
2402        if display_width(&candidate) + 3 > max_width {
2403            break;
2404        }
2405        out.push(ch);
2406    }
2407    out.push_str("...");
2408    out
2409}
2410
2411pub(crate) fn render_markdown_lines(text: &str, width: usize, indent: usize) -> Vec<Line<'static>> {
2412    render_markdown(text, width, indent).lines
2413}
2414
2415fn render_markdown(text: &str, width: usize, indent: usize) -> MarkdownRender {
2416    let mut renderer = MarkdownRenderer::new(width, indent);
2417    let options = Options::ENABLE_GFM
2418        | Options::ENABLE_STRIKETHROUGH
2419        | Options::ENABLE_TASKLISTS
2420        | Options::ENABLE_TABLES
2421        | Options::ENABLE_FOOTNOTES
2422        | Options::ENABLE_SUPERSCRIPT
2423        | Options::ENABLE_SUBSCRIPT
2424        | Options::ENABLE_MATH;
2425    let parser = Parser::new_ext(text, options);
2426    let parser = TextMergeStream::new(parser);
2427    for event in parser {
2428        match event {
2429            MdEvent::Start(tag) => renderer.start_tag(tag),
2430            MdEvent::End(tag) => renderer.end_tag(tag),
2431            MdEvent::Text(text) => renderer.text(&text),
2432            MdEvent::Code(text) => renderer.inline_code(&text),
2433            MdEvent::InlineMath(text) | MdEvent::DisplayMath(text) => renderer.inline_math(&text),
2434            MdEvent::SoftBreak => renderer.soft_break(),
2435            MdEvent::HardBreak => renderer.hard_break(),
2436            MdEvent::Html(text) | MdEvent::InlineHtml(text) => renderer.text(&text),
2437            MdEvent::Rule => renderer.rule(),
2438            MdEvent::TaskListMarker(checked) => renderer.task_list_marker(checked),
2439            _ => {}
2440        }
2441    }
2442    renderer.finish()
2443}
2444
2445struct MarkdownRenderer {
2446    lines: Vec<Line<'static>>,
2447    links: Vec<RenderedLink>,
2448    current_line: Vec<Span<'static>>,
2449    current_width: usize,
2450    max_width: usize,
2451    indent: usize,
2452    style_stack: Vec<Style>,
2453    current_style: Style,
2454    in_block_quote: bool,
2455    block_quote_style: Option<AdmonitionStyle>,
2456    block_quote_title_pending: bool,
2457    in_code_block: bool,
2458    code_block_lang: Option<String>,
2459    code_block_buf: String,
2460    list_prefix: Option<String>,
2461    pending_space: bool,
2462    active_link_url: Option<String>,
2463}
2464
2465#[derive(Clone, Copy)]
2466struct AdmonitionStyle {
2467    marker: &'static str,
2468    default_title: &'static str,
2469    border_color: Color,
2470    title_style: Style,
2471}
2472
2473impl AdmonitionStyle {
2474    fn from_block_quote_kind(kind: BlockQuoteKind) -> Option<Self> {
2475        match kind {
2476            BlockQuoteKind::Note => Some(Self {
2477                marker: "NOTE",
2478                default_title: "Note",
2479                border_color: Color::Blue,
2480                title_style: Style::new().fg(Color::Blue).add_modifier(Modifier::BOLD),
2481            }),
2482            BlockQuoteKind::Tip => Some(Self {
2483                marker: "TIP",
2484                default_title: "Tip",
2485                border_color: Color::Green,
2486                title_style: Style::new().fg(Color::Green).add_modifier(Modifier::BOLD),
2487            }),
2488            BlockQuoteKind::Important => Some(Self {
2489                marker: "IMPORTANT",
2490                default_title: "Important",
2491                border_color: Color::Cyan,
2492                title_style: Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
2493            }),
2494            BlockQuoteKind::Warning => Some(Self {
2495                marker: "WARNING",
2496                default_title: "Warning",
2497                border_color: Color::Yellow,
2498                title_style: Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD),
2499            }),
2500            BlockQuoteKind::Caution => Some(Self {
2501                marker: "CAUTION",
2502                default_title: "Caution",
2503                border_color: Color::Red,
2504                title_style: Style::new().fg(Color::Red).add_modifier(Modifier::BOLD),
2505            }),
2506        }
2507    }
2508}
2509
2510impl MarkdownRenderer {
2511    fn new(max_width: usize, indent: usize) -> Self {
2512        Self {
2513            lines: Vec::new(),
2514            links: Vec::new(),
2515            current_line: Vec::new(),
2516            current_width: 0,
2517            max_width: max_width.max(10),
2518            indent,
2519            style_stack: Vec::new(),
2520            current_style: Style::new(),
2521            in_block_quote: false,
2522            block_quote_style: None,
2523            block_quote_title_pending: false,
2524            in_code_block: false,
2525            code_block_lang: None,
2526            code_block_buf: String::new(),
2527            list_prefix: None,
2528            pending_space: false,
2529            active_link_url: None,
2530        }
2531    }
2532
2533    fn start_tag(&mut self, tag: Tag) {
2534        match tag {
2535            Tag::Emphasis => self.push_style(Style::new().add_modifier(Modifier::ITALIC)),
2536            Tag::Strong => self.push_style(Style::new().add_modifier(Modifier::BOLD)),
2537            Tag::Strikethrough => self.push_style(Style::new().add_modifier(Modifier::CROSSED_OUT)),
2538            Tag::Superscript | Tag::Subscript => {
2539                self.push_style(Style::new().add_modifier(Modifier::ITALIC))
2540            }
2541            Tag::Link { dest_url, .. } => {
2542                self.active_link_url = Some(dest_url.to_string());
2543                self.push_style(
2544                    Style::new()
2545                        .fg(Color::Blue)
2546                        .add_modifier(Modifier::UNDERLINED),
2547                );
2548            }
2549            Tag::Heading { .. } => {
2550                self.push_style(Style::new().add_modifier(Modifier::BOLD));
2551            }
2552            Tag::BlockQuote(kind) => {
2553                self.flush_line();
2554                self.in_block_quote = true;
2555                self.block_quote_style = kind.and_then(AdmonitionStyle::from_block_quote_kind);
2556                self.block_quote_title_pending = self.block_quote_style.is_some();
2557            }
2558            Tag::CodeBlock(kind) => {
2559                self.ensure_admonition_header();
2560                self.flush_line();
2561                self.in_code_block = true;
2562                self.code_block_lang = code_block_kind_lang(kind);
2563                self.code_block_buf.clear();
2564            }
2565            Tag::Item => {
2566                self.flush_line();
2567                self.list_prefix = Some("• ".to_string());
2568            }
2569            _ => {}
2570        }
2571    }
2572
2573    fn end_tag(&mut self, tag: TagEnd) {
2574        match tag {
2575            TagEnd::Emphasis
2576            | TagEnd::Strong
2577            | TagEnd::Strikethrough
2578            | TagEnd::Superscript
2579            | TagEnd::Subscript
2580            | TagEnd::Link => {
2581                if matches!(tag, TagEnd::Link) {
2582                    self.active_link_url = None;
2583                }
2584                self.pop_style();
2585            }
2586            TagEnd::Heading(_) => {
2587                self.pop_style();
2588                self.flush_line();
2589            }
2590            TagEnd::BlockQuote(_) => {
2591                self.flush_line();
2592                self.in_block_quote = false;
2593                self.block_quote_style = None;
2594                self.block_quote_title_pending = false;
2595                self.push_blank_line();
2596            }
2597            TagEnd::CodeBlock => {
2598                self.render_code_block();
2599                self.flush_line();
2600                self.in_code_block = false;
2601                self.code_block_lang = None;
2602                self.code_block_buf.clear();
2603                self.push_blank_line();
2604            }
2605            TagEnd::Item => {
2606                self.flush_line();
2607                self.list_prefix = None;
2608            }
2609            TagEnd::Paragraph => {
2610                self.flush_line();
2611                self.push_blank_line();
2612            }
2613            _ => {}
2614        }
2615    }
2616
2617    fn text(&mut self, text: &str) {
2618        if self.in_block_quote && self.block_quote_title_pending {
2619            if let Some(style) = self.block_quote_style
2620                && let Some(title) = extract_admonition_title(text, style.marker)
2621            {
2622                let title = if title.is_empty() {
2623                    style.default_title
2624                } else {
2625                    title
2626                };
2627                self.push_admonition_header(title, style);
2628                self.block_quote_title_pending = false;
2629                return;
2630            }
2631            self.ensure_admonition_header();
2632        }
2633        if self.in_code_block {
2634            self.code_block_text(text);
2635        } else {
2636            let style = self.current_style;
2637            self.push_text(text, style);
2638        }
2639    }
2640
2641    fn inline_code(&mut self, text: &str) {
2642        self.ensure_admonition_header();
2643        let style = self
2644            .current_style
2645            .patch(Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD));
2646        self.push_text(text, style);
2647    }
2648
2649    fn inline_math(&mut self, text: &str) {
2650        self.ensure_admonition_header();
2651        let style = self.current_style.patch(
2652            Style::new()
2653                .fg(Color::LightMagenta)
2654                .add_modifier(Modifier::ITALIC),
2655        );
2656        self.push_text(text, style);
2657    }
2658
2659    fn soft_break(&mut self) {
2660        self.ensure_admonition_header();
2661        if self.in_code_block {
2662            self.code_block_buf.push('\n');
2663        } else {
2664            self.pending_space = true;
2665        }
2666    }
2667
2668    fn hard_break(&mut self) {
2669        self.ensure_admonition_header();
2670        if self.in_code_block {
2671            self.code_block_buf.push('\n');
2672            return;
2673        }
2674        self.flush_line();
2675    }
2676
2677    fn task_list_marker(&mut self, checked: bool) {
2678        self.ensure_admonition_header();
2679        let marker = if checked { "[x] " } else { "[ ] " };
2680        self.push_text(marker, self.current_style);
2681    }
2682
2683    fn rule(&mut self) {
2684        self.flush_line();
2685        self.start_line();
2686        let width = self.max_width.saturating_sub(self.prefix_width()).max(8);
2687        let bar = "─".repeat(width);
2688        self.current_line
2689            .push(Span::styled(bar.clone(), Style::new().fg(Color::DarkGray)));
2690        self.current_width += display_width(&bar);
2691        self.flush_line();
2692        self.push_blank_line();
2693    }
2694
2695    fn push_text(&mut self, text: &str, style: Style) {
2696        let mut buffer = String::new();
2697        for ch in text.chars() {
2698            if ch == '\n' {
2699                if !buffer.is_empty() {
2700                    self.push_word(&buffer, style);
2701                    buffer.clear();
2702                }
2703                self.flush_line();
2704                continue;
2705            }
2706            if ch.is_whitespace() {
2707                if !buffer.is_empty() {
2708                    self.push_word(&buffer, style);
2709                    buffer.clear();
2710                }
2711                self.pending_space = true;
2712            } else {
2713                buffer.push(ch);
2714            }
2715        }
2716        if !buffer.is_empty() {
2717            self.push_word(&buffer, style);
2718        }
2719    }
2720
2721    fn push_word(&mut self, word: &str, style: Style) {
2722        let prefix_width = self.prefix_width();
2723        let max_width = self.max_width;
2724        let word_width = display_width(word);
2725        let space_width = if self.pending_space && self.current_width > prefix_width {
2726            1
2727        } else {
2728            0
2729        };
2730
2731        if word_width > max_width.saturating_sub(prefix_width) {
2732            self.push_long_word(word, style);
2733            self.pending_space = false;
2734            return;
2735        }
2736
2737        if self.current_line.is_empty() {
2738            self.start_line();
2739        }
2740
2741        if self.current_width + space_width + word_width > max_width
2742            && self.current_width > prefix_width
2743        {
2744            self.flush_line();
2745            self.start_line();
2746        }
2747
2748        if self.pending_space && self.current_width > prefix_width {
2749            let space_col = self.current_width;
2750            self.current_line.push(Span::raw(" "));
2751            self.current_width += 1;
2752            if self.should_attach_space_to_active_link(space_col) {
2753                self.push_link_segment(" ", space_col, 1);
2754            }
2755        }
2756        self.pending_space = false;
2757
2758        let link_start_col = self.current_width;
2759        self.current_line
2760            .push(Span::styled(word.to_string(), style));
2761        self.current_width += word_width;
2762        self.push_link_segment(word, link_start_col, word_width);
2763    }
2764
2765    fn push_long_word(&mut self, word: &str, style: Style) {
2766        let available = self.max_width.saturating_sub(self.prefix_width()).max(1);
2767        let wrapped = textwrap::wrap(word, textwrap::Options::new(available).break_words(true));
2768        for (idx, part) in wrapped.iter().enumerate() {
2769            if idx > 0 {
2770                self.flush_line();
2771            }
2772            if self.current_line.is_empty() {
2773                self.start_line();
2774            }
2775            let link_start_col = self.current_width;
2776            let part_width = display_width(part);
2777            self.current_line
2778                .push(Span::styled(part.to_string(), style));
2779            self.current_width += part_width;
2780            self.push_link_segment(part, link_start_col, part_width);
2781        }
2782    }
2783
2784    fn push_link_segment(&mut self, label: &str, col: usize, width: usize) {
2785        let Some(url) = self.active_link_url.as_ref() else {
2786            return;
2787        };
2788        if label.is_empty() || width == 0 {
2789            return;
2790        }
2791
2792        let line = self.current_line_index();
2793        if let Some(last) = self.links.last_mut()
2794            && last.url == *url
2795            && last.line == line
2796            && last.col + last.width == col
2797        {
2798            last.label.push_str(label);
2799            last.width += width;
2800            return;
2801        }
2802
2803        self.links.push(RenderedLink {
2804            line,
2805            col,
2806            label: label.to_string(),
2807            url: url.clone(),
2808            width,
2809        });
2810    }
2811
2812    fn should_attach_space_to_active_link(&self, space_col: usize) -> bool {
2813        let Some(url) = self.active_link_url.as_ref() else {
2814            return false;
2815        };
2816        let line = self.current_line_index();
2817        self.links.last().is_some_and(|last| {
2818            last.url == *url && last.line == line && last.col + last.width == space_col
2819        })
2820    }
2821
2822    fn current_line_index(&self) -> usize {
2823        self.lines.len()
2824    }
2825
2826    fn code_block_text(&mut self, text: &str) {
2827        self.code_block_buf.push_str(text);
2828    }
2829
2830    fn render_code_block(&mut self) {
2831        if self.code_block_buf.is_empty() {
2832            return;
2833        }
2834
2835        let code = std::mem::take(&mut self.code_block_buf);
2836        let assets = syntect_assets();
2837        let syntax = resolve_syntax(&assets.syntaxes, self.code_block_lang.as_deref());
2838        let mut highlighter = HighlightLines::new(syntax, &assets.theme);
2839        let fallback_style = Style::new().light_yellow();
2840
2841        for raw_line in code.split('\n') {
2842            self.flush_line();
2843            self.start_line();
2844            match highlighter.highlight_line(raw_line, &assets.syntaxes) {
2845                Ok(regions) => {
2846                    for (syn_style, fragment) in regions {
2847                        if fragment.is_empty() {
2848                            continue;
2849                        }
2850                        self.current_line.push(Span::styled(
2851                            fragment.to_string(),
2852                            syntect_style_to_ratatui(syn_style),
2853                        ));
2854                        self.current_width += display_width(fragment);
2855                    }
2856                }
2857                Err(_) => {
2858                    if !raw_line.is_empty() {
2859                        self.current_line
2860                            .push(Span::styled(raw_line.to_string(), fallback_style));
2861                        self.current_width += display_width(raw_line);
2862                    }
2863                }
2864            }
2865            self.flush_line();
2866        }
2867    }
2868
2869    fn start_line(&mut self) {
2870        if !self.current_line.is_empty() {
2871            return;
2872        }
2873        if self.indent > 0 {
2874            let indent = " ".repeat(self.indent);
2875            self.current_width += self.indent;
2876            self.current_line.push(Span::raw(indent));
2877        }
2878        if self.in_block_quote {
2879            self.current_width += 2;
2880            let border_style = self
2881                .block_quote_style
2882                .map(|s| Style::new().fg(s.border_color))
2883                .unwrap_or_else(|| Style::new().fg(Color::DarkGray));
2884            self.current_line.push(Span::styled("│ ", border_style));
2885        }
2886        if let Some(prefix) = &self.list_prefix {
2887            self.current_width += display_width(prefix);
2888            self.current_line.push(Span::raw(prefix.clone()));
2889        }
2890    }
2891
2892    fn prefix_width(&self) -> usize {
2893        let mut width = self.indent;
2894        if self.in_block_quote {
2895            width += 2;
2896        }
2897        if let Some(prefix) = &self.list_prefix {
2898            width += display_width(prefix);
2899        }
2900        width
2901    }
2902
2903    fn flush_line(&mut self) {
2904        if self.current_line.is_empty() {
2905            self.pending_space = false;
2906            return;
2907        }
2908        let line = Line::from(std::mem::take(&mut self.current_line));
2909        self.lines.push(line);
2910        self.current_width = 0;
2911        self.pending_space = false;
2912    }
2913
2914    fn push_blank_line(&mut self) {
2915        if self.lines.last().is_some_and(|line| line.spans.is_empty()) {
2916            return;
2917        }
2918        self.lines.push(Line::from(Vec::<Span<'static>>::new()));
2919    }
2920
2921    fn push_style(&mut self, style: Style) {
2922        self.style_stack.push(self.current_style);
2923        self.current_style = self.current_style.patch(style);
2924    }
2925
2926    fn pop_style(&mut self) {
2927        if let Some(prev) = self.style_stack.pop() {
2928            self.current_style = prev;
2929        }
2930    }
2931
2932    fn finish(mut self) -> MarkdownRender {
2933        self.flush_line();
2934        while self.lines.last().is_some_and(|line| line.spans.is_empty()) {
2935            self.lines.pop();
2936        }
2937        if self.lines.is_empty() {
2938            self.lines.push(Line::from(vec![Span::raw("")]));
2939        }
2940        MarkdownRender {
2941            lines: self.lines,
2942            links: self.links,
2943        }
2944    }
2945
2946    fn ensure_admonition_header(&mut self) {
2947        if !self.block_quote_title_pending {
2948            return;
2949        }
2950        if let Some(style) = self.block_quote_style {
2951            self.push_admonition_header(style.default_title, style);
2952        }
2953        self.block_quote_title_pending = false;
2954    }
2955
2956    fn push_admonition_header(&mut self, title: &str, style: AdmonitionStyle) {
2957        self.flush_line();
2958        self.start_line();
2959        self.current_line
2960            .push(Span::styled(title.to_string(), style.title_style));
2961        self.current_width += display_width(title);
2962        self.flush_line();
2963    }
2964}
2965
2966fn extract_admonition_title<'a>(text: &'a str, marker: &str) -> Option<&'a str> {
2967    let trimmed = text.trim_start();
2968    let min_len = marker.len() + 3;
2969    if trimmed.len() < min_len {
2970        return None;
2971    }
2972    let bytes = trimmed.as_bytes();
2973    if bytes[0] != b'[' || bytes[1] != b'!' {
2974        return None;
2975    }
2976    let marker_end = 2 + marker.len();
2977    if bytes.get(marker_end) != Some(&b']') {
2978        return None;
2979    }
2980    if !trimmed[2..marker_end].eq_ignore_ascii_case(marker) {
2981        return None;
2982    }
2983    Some(trimmed[marker_end + 1..].trim())
2984}
2985
2986fn code_block_kind_lang(kind: CodeBlockKind<'_>) -> Option<String> {
2987    match kind {
2988        CodeBlockKind::Indented => None,
2989        CodeBlockKind::Fenced(info) => parse_fenced_language(&info).map(|lang| lang.to_lowercase()),
2990    }
2991}
2992
2993fn parse_fenced_language(info: &str) -> Option<&str> {
2994    let token = info
2995        .split_ascii_whitespace()
2996        .next()
2997        .unwrap_or_default()
2998        .split(',')
2999        .next()
3000        .unwrap_or_default()
3001        .trim_matches(|c| c == '{' || c == '}');
3002    let token = token.strip_prefix('.').unwrap_or(token);
3003    if token.is_empty() { None } else { Some(token) }
3004}
3005
3006fn resolve_syntax<'a>(syntaxes: &'a SyntaxSet, lang: Option<&str>) -> &'a SyntaxReference {
3007    if let Some(lang) = lang {
3008        if let Some(syntax) = syntaxes.find_syntax_by_token(lang) {
3009            return syntax;
3010        }
3011        if let Some(stripped) = lang.strip_prefix("language-")
3012            && let Some(syntax) = syntaxes.find_syntax_by_token(stripped)
3013        {
3014            return syntax;
3015        }
3016        if let Some(syntax) = syntaxes.find_syntax_by_extension(lang) {
3017            return syntax;
3018        }
3019    }
3020    syntaxes.find_syntax_plain_text()
3021}
3022
3023fn syntect_style_to_ratatui(style: syntect::highlighting::Style) -> Style {
3024    let mut out = Style::new().fg(Color::Rgb(
3025        style.foreground.r,
3026        style.foreground.g,
3027        style.foreground.b,
3028    ));
3029    if style.font_style.contains(FontStyle::BOLD) {
3030        out = out.add_modifier(Modifier::BOLD);
3031    }
3032    if style.font_style.contains(FontStyle::ITALIC) {
3033        out = out.add_modifier(Modifier::ITALIC);
3034    }
3035    if style.font_style.contains(FontStyle::UNDERLINE) {
3036        out = out.add_modifier(Modifier::UNDERLINED);
3037    }
3038    out
3039}
3040
3041#[cfg(test)]
3042mod tests {
3043    use super::render_markdown;
3044
3045    fn line_text(rendered: &super::MarkdownRender, idx: usize) -> String {
3046        rendered.lines[idx]
3047            .spans
3048            .iter()
3049            .map(|s| s.content.as_ref())
3050            .collect()
3051    }
3052
3053    #[test]
3054    fn extracts_link_segments_with_urls() {
3055        let rendered = render_markdown("Go to [ratatui docs](https://github.com/ratatui/).", 80, 0);
3056
3057        assert!(!rendered.links.is_empty());
3058        assert!(
3059            rendered
3060                .links
3061                .iter()
3062                .all(|link| link.url == "https://github.com/ratatui/")
3063        );
3064    }
3065
3066    #[test]
3067    fn wraps_long_links_into_multiple_segments() {
3068        let rendered = render_markdown("[A very long linked label](https://example.com)", 12, 2);
3069
3070        assert!(rendered.links.len() >= 2);
3071    }
3072
3073    #[test]
3074    fn keeps_spaces_around_plain_links() {
3075        let rendered = render_markdown("left https://google.com right", 80, 0);
3076
3077        assert_eq!(line_text(&rendered, 0), "left https://google.com right");
3078        assert!(
3079            rendered
3080                .links
3081                .iter()
3082                .all(|link| !link.label.starts_with(' ') && !link.label.ends_with(' '))
3083        );
3084    }
3085}