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            toast::{ToastPosition, ToastType},
50        },
51        layout::Layout,
52        toast_action,
53        utils::get_border_style,
54    },
55};
56use anyhow::anyhow;
57use hyperrat::Link;
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                        let Some(current) = self.current.as_ref() else {
1464                            return Ok(());
1465                        };
1466                        let Some(selected_idx) = self.list_state.selected_checked() else {
1467                            return Ok(());
1468                        };
1469
1470                        let Some(selected) = self.message_keys.get(selected_idx) else {
1471                            return Ok(());
1472                        };
1473
1474                        match selected {
1475                            MessageKey::IssueBody(i) => {
1476                                assert_eq!(*i, current.number);
1477                                let link = format!(
1478                                    "https://github.com/{}/{}/issues/{}",
1479                                    self.owner, self.repo, i
1480                                );
1481                                cli_clipboard::set_contents(link)
1482                                    .map_err(|_| anyhow!("Error copying to clipboard"))?;
1483                            }
1484                            MessageKey::Comment(id) => {
1485                                let link = format!(
1486                                    "https://github.com/{}/{}/issues/{}#issuecomment-{}",
1487                                    self.owner, self.repo, current.number, id
1488                                );
1489
1490                                cli_clipboard::set_contents(link)
1491                                    .map_err(|_| anyhow!("Error copying to clipboard"))?;
1492                            }
1493                            _ => {
1494                                return Ok(());
1495                            }
1496                        }
1497                        if let Some(tx) = self.action_tx.clone() {
1498                            tx.send(Action::ToastAction(
1499                                crate::ui::components::toast::ToastMessage::Show {
1500                                    message: "Copied Link".to_string(),
1501                                    toast_type: ToastType::Success,
1502                                    position: ToastPosition::TopRight,
1503                                },
1504                            ))
1505                            .await?;
1506                            tx.send(Action::ForceRender).await?;
1507                        }
1508                    }
1509                    event::Event::Key(key)
1510                        if key.code == event::KeyCode::Char('f')
1511                            && key.modifiers == event::KeyModifiers::NONE
1512                            && self.screen == MainScreen::Details
1513                            && self.body_paragraph_state.is_focused() =>
1514                    {
1515                        if let Some(tx) = self.action_tx.clone() {
1516                            let _ = tx
1517                                .send(Action::ChangeIssueScreen(MainScreen::DetailsFullscreen))
1518                                .await;
1519                        }
1520                        return Ok(());
1521                    }
1522                    event::Event::Key(key)
1523                        if key.code == event::KeyCode::Char('e')
1524                            && key.modifiers == event::KeyModifiers::NONE
1525                            && (self.list_state.is_focused()
1526                                || self.body_paragraph_state.is_focused()) =>
1527                    {
1528                        let seed = self.current.as_ref().ok_or_else(|| {
1529                            AppError::Other(anyhow!("no issue selected for comment editing"))
1530                        })?;
1531                        let comment = self
1532                            .selected_comment()
1533                            .ok_or_else(|| AppError::Other(anyhow!("select a comment to edit")))?;
1534                        self.open_external_editor_for_comment(
1535                            seed.number,
1536                            comment.id,
1537                            comment.body.to_string(),
1538                        )
1539                        .await;
1540                        return Ok(());
1541                    }
1542                    event::Event::Key(key)
1543                        if key.code == event::KeyCode::Char('r')
1544                            && key.modifiers == event::KeyModifiers::NONE
1545                            && self.list_state.is_focused() =>
1546                    {
1547                        self.start_add_reaction_mode();
1548                        return Ok(());
1549                    }
1550                    event::Event::Key(key)
1551                        if key.code == event::KeyCode::Char('R')
1552                            && self.list_state.is_focused() =>
1553                    {
1554                        self.start_remove_reaction_mode();
1555                        return Ok(());
1556                    }
1557                    event::Event::Key(key)
1558                        if key.code == event::KeyCode::Char('C')
1559                            && (self.list_state.is_focused()
1560                                || self.body_paragraph_state.is_focused()) =>
1561                    {
1562                        self.open_close_popup();
1563                        return Ok(());
1564                    }
1565                    ct_event!(keycode press Tab) | ct_event!(keycode press BackTab)
1566                        if self.input_state.is_focused() =>
1567                    {
1568                        let action_tx = self.action_tx.as_ref().ok_or_else(|| {
1569                            AppError::Other(anyhow!(
1570                                "issue conversation action channel unavailable"
1571                            ))
1572                        })?;
1573                        action_tx.send(Action::ForceFocusChange).await?;
1574                    }
1575                    ct_event!(keycode press Esc) if self.body_paragraph_state.is_focused() => {
1576                        let action_tx = self.action_tx.as_ref().ok_or_else(|| {
1577                            AppError::Other(anyhow!(
1578                                "issue conversation action channel unavailable"
1579                            ))
1580                        })?;
1581                        action_tx.send(Action::ForceFocusChangeRev).await?;
1582                    }
1583                    ct_event!(keycode press Esc) if !self.body_paragraph_state.is_focused() => {
1584                        if let Some(tx) = self.action_tx.clone() {
1585                            let _ = tx.send(Action::ChangeIssueScreen(MainScreen::List)).await;
1586                        }
1587                        return Ok(());
1588                    }
1589                    ct_event!(key press CONTROL-'p') => {
1590                        self.textbox_state.toggle();
1591                        match self.textbox_state {
1592                            InputState::Input => {
1593                                self.input_state.focus.set(true);
1594                                self.paragraph_state.focus.set(false);
1595                            }
1596                            InputState::Preview => {
1597                                self.input_state.focus.set(false);
1598                                self.paragraph_state.focus.set(true);
1599                            }
1600                        }
1601                        if let Some(ref tx) = self.action_tx {
1602                            let _ = tx.send(Action::ForceRender).await;
1603                        }
1604                    }
1605                    ct_event!(keycode press Enter) if self.list_state.is_focused() => {
1606                        let action_tx = self.action_tx.as_ref().ok_or_else(|| {
1607                            AppError::Other(anyhow!(
1608                                "issue conversation action channel unavailable"
1609                            ))
1610                        })?;
1611                        action_tx.send(Action::ForceFocusChange).await?;
1612                    }
1613                    ct_event!(keycode press CONTROL-Enter) | ct_event!(keycode press ALT-Enter) => {
1614                        let Some(seed) = &self.current else {
1615                            return Ok(());
1616                        };
1617                        let body = self.input_state.text();
1618                        let trimmed = body.trim();
1619                        if trimmed.is_empty() {
1620                            self.post_error = Some("Comment cannot be empty.".to_string());
1621                            return Ok(());
1622                        }
1623                        self.input_state.set_text("");
1624                        self.send_comment(seed.number, trimmed.to_string()).await;
1625                        return Ok(());
1626                    }
1627
1628                    ct_event!(key press '>')
1629                        if self.list_state.is_focused()
1630                            || self.body_paragraph_state.is_focused() =>
1631                    {
1632                        if let Some(comment) = self.selected_comment() {
1633                            let comment_body = comment.body.as_ref();
1634                            let quoted = comment_body
1635                                .lines()
1636                                .map(|line| format!("> {}", line.trim()))
1637                                .collect::<Vec<_>>()
1638                                .join("\n");
1639                            self.input_state.insert_str(&quoted);
1640                            self.input_state.insert_newline();
1641                            self.input_state.move_to_end(false);
1642                            self.input_state.move_to_line_end(false);
1643                            self.input_state.focus.set(true);
1644                            self.list_state.focus.set(false);
1645                        }
1646                    }
1647
1648                    event::Event::Key(key) if key.code != event::KeyCode::Tab => {
1649                        let o = self.input_state.handle(event, rat_widget::event::Regular);
1650                        let o2 = self
1651                            .paragraph_state
1652                            .handle(event, rat_widget::event::Regular);
1653                        if matches!(
1654                            event,
1655                            ct_event!(keycode press Up)
1656                                | ct_event!(keycode press Down)
1657                                | ct_event!(keycode press Left)
1658                                | ct_event!(keycode press Right)
1659                        ) {
1660                            let action_tx = self.action_tx.as_ref().ok_or_else(|| {
1661                                AppError::Other(anyhow!(
1662                                    "issue conversation action channel unavailable"
1663                                ))
1664                            })?;
1665                            action_tx.send(Action::ForceRender).await?;
1666                        }
1667                        if o == TextOutcome::TextChanged || o2 == Outcome::Changed {
1668                            trace!("Input changed, forcing re-render");
1669                            let action_tx = self.action_tx.as_ref().ok_or_else(|| {
1670                                AppError::Other(anyhow!(
1671                                    "issue conversation action channel unavailable"
1672                                ))
1673                            })?;
1674                            action_tx.send(Action::ForceRender).await?;
1675                        }
1676                    }
1677                    event::Event::Paste(p) if self.input_state.is_focused() => {
1678                        self.input_state.insert_str(p);
1679                        let action_tx = self.action_tx.as_ref().ok_or_else(|| {
1680                            AppError::Other(anyhow!(
1681                                "issue conversation action channel unavailable"
1682                            ))
1683                        })?;
1684                        action_tx.send(Action::ForceRender).await?;
1685                    }
1686                    _ => {}
1687                }
1688                self.body_paragraph_state
1689                    .handle(event, rat_widget::event::Regular);
1690                let outcome = self.list_state.handle(event, rat_widget::event::Regular);
1691                if outcome == rat_widget::event::Outcome::Changed {
1692                    self.body_paragraph_state.set_line_offset(0);
1693                }
1694            }
1695            Action::EnterIssueDetails { seed } => {
1696                let number = seed.number;
1697                self.title = seed.title.clone();
1698                self.current = Some(seed);
1699                self.post_error = None;
1700                self.reaction_error = None;
1701                self.close_error = None;
1702                self.reaction_mode = None;
1703                self.close_popup = None;
1704                self.timeline_error = None;
1705                self.body_cache = None;
1706                self.body_cache_number = Some(number);
1707                self.body_paragraph_state.set_line_offset(0);
1708                if self.cache_number != Some(number) {
1709                    self.cache_number = None;
1710                    self.cache_comments.clear();
1711                    self.markdown_cache.clear();
1712                }
1713                if self.timeline_cache_number != Some(number) {
1714                    self.timeline_cache_number = None;
1715                    self.cache_timeline.clear();
1716                }
1717                if self.cache_number == Some(number) {
1718                    self.loading.remove(&number);
1719                    self.error = None;
1720                } else {
1721                    self.fetch_comments(number).await;
1722                }
1723                if self.show_timeline {
1724                    if self.has_timeline_for(number) {
1725                        self.timeline_loading.remove(&number);
1726                    } else {
1727                        self.fetch_timeline(number).await;
1728                    }
1729                }
1730            }
1731            Action::IssueCommentsLoaded { number, comments } => {
1732                self.loading.remove(&number);
1733                if self.current.as_ref().is_some_and(|s| s.number == number) {
1734                    self.cache_number = Some(number);
1735                    trace!("Setting {} comments for #{}", comments.len(), number);
1736                    self.cache_comments = comments;
1737                    self.markdown_cache.clear();
1738                    self.body_cache = None;
1739                    self.body_paragraph_state.set_line_offset(0);
1740                    self.error = None;
1741                    let action_tx = self.action_tx.as_ref().ok_or_else(|| {
1742                        AppError::Other(anyhow!("issue conversation action channel unavailable"))
1743                    })?;
1744                    action_tx.send(Action::ForceRender).await?;
1745                }
1746            }
1747            Action::IssueReactionsLoaded {
1748                reactions,
1749                own_reactions,
1750            } => {
1751                self.reaction_error = None;
1752                for (id, reaction_content) in reactions {
1753                    if let Some(comment) = self.cache_comments.iter_mut().find(|c| c.id == id) {
1754                        comment.reactions = Some(reaction_content);
1755                        comment.my_reactions =
1756                            Some(own_reactions.get(&id).cloned().unwrap_or_else(Vec::new));
1757                    }
1758                }
1759            }
1760            Action::IssueReactionEditError {
1761                comment_id: _,
1762                message,
1763            } => {
1764                self.reaction_error = Some(message);
1765            }
1766            Action::IssueCommentPosted { number, comment } => {
1767                self.posting = false;
1768                if self.current.as_ref().is_some_and(|s| s.number == number) {
1769                    if self.cache_number == Some(number) {
1770                        self.cache_comments.push(comment);
1771                    } else {
1772                        self.cache_number = Some(number);
1773                        self.cache_comments.clear();
1774                        self.cache_comments.push(comment);
1775                        self.markdown_cache.clear();
1776                        self.body_cache = None;
1777                    }
1778                }
1779            }
1780            Action::IssueCommentsError { number, message } => {
1781                self.loading.remove(&number);
1782                if self.current.as_ref().is_some_and(|s| s.number == number) {
1783                    self.error = Some(message);
1784                }
1785            }
1786            Action::IssueTimelineLoaded { number, events } => {
1787                self.timeline_loading.remove(&number);
1788                if self.current.as_ref().is_some_and(|s| s.number == number) {
1789                    self.timeline_cache_number = Some(number);
1790                    self.cache_timeline = events;
1791                    self.timeline_error = None;
1792                    if let Some(action_tx) = self.action_tx.as_ref() {
1793                        let _ = action_tx.send(Action::ForceRender).await;
1794                    }
1795                }
1796            }
1797            Action::IssueTimelineError { number, message } => {
1798                self.timeline_loading.remove(&number);
1799                if self.current.as_ref().is_some_and(|s| s.number == number) {
1800                    self.timeline_error = Some(message);
1801                }
1802            }
1803            Action::IssueCommentPostError { number, message } => {
1804                self.posting = false;
1805                if self.current.as_ref().is_some_and(|s| s.number == number) {
1806                    self.post_error = Some(message);
1807                }
1808            }
1809            Action::IssueCommentEditFinished {
1810                issue_number,
1811                comment_id,
1812                result,
1813            } => {
1814                if self
1815                    .current
1816                    .as_ref()
1817                    .is_none_or(|seed| seed.number != issue_number)
1818                {
1819                    return Ok(());
1820                }
1821                match result {
1822                    Ok(body) => {
1823                        let Some(existing) =
1824                            self.cache_comments.iter().find(|c| c.id == comment_id)
1825                        else {
1826                            return Err(AppError::Other(anyhow!(
1827                                "selected comment is no longer available"
1828                            )));
1829                        };
1830                        if body == existing.body.as_ref() {
1831                            return Ok(());
1832                        }
1833                        let trimmed = body.trim();
1834                        if trimmed.is_empty() {
1835                            return Err(AppError::Other(anyhow!(
1836                                "comment cannot be empty after editing"
1837                            )));
1838                        }
1839                        self.patch_comment(issue_number, comment_id, trimmed.to_string())
1840                            .await;
1841                        if let Some(action_tx) = self.action_tx.as_ref() {
1842                            action_tx.send(Action::ForceRender).await?;
1843                        }
1844                    }
1845                    Err(message) => {
1846                        return Err(AppError::Other(anyhow!("comment edit failed: {message}")));
1847                    }
1848                }
1849            }
1850            Action::IssueCommentPatched {
1851                issue_number,
1852                comment,
1853            } => {
1854                if self
1855                    .current
1856                    .as_ref()
1857                    .is_some_and(|seed| seed.number == issue_number)
1858                    && let Some(existing) =
1859                        self.cache_comments.iter_mut().find(|c| c.id == comment.id)
1860                {
1861                    let reactions = existing.reactions.clone();
1862                    let my_reactions = existing.my_reactions.clone();
1863                    *existing = comment;
1864                    existing.reactions = reactions;
1865                    existing.my_reactions = my_reactions;
1866                    self.markdown_cache.remove(&existing.id);
1867                }
1868            }
1869            Action::IssueCloseSuccess { issue } => {
1870                let issue = *issue;
1871                let initiated_here = self
1872                    .close_popup
1873                    .as_ref()
1874                    .is_some_and(|popup| popup.issue_number == issue.number);
1875                if initiated_here {
1876                    self.close_popup = None;
1877                    self.close_error = None;
1878                    if let Some(action_tx) = self.action_tx.as_ref() {
1879                        let _ = action_tx
1880                            .send(Action::SelectedIssuePreview {
1881                                seed: crate::ui::components::issue_detail::IssuePreviewSeed::from_issue(
1882                                    &issue,
1883                                ),
1884                            })
1885                            .await;
1886                        let _ = action_tx.send(Action::RefreshIssueList).await;
1887                    }
1888                }
1889            }
1890            Action::IssueCloseError { number, message } => {
1891                if let Some(popup) = self.close_popup.as_mut()
1892                    && popup.issue_number == number
1893                {
1894                    popup.loading = false;
1895                    popup.error = Some(message.clone());
1896                    self.close_error = Some(message);
1897                }
1898            }
1899            Action::ChangeIssueScreen(screen) => {
1900                self.screen = screen;
1901                match screen {
1902                    MainScreen::List => {
1903                        self.input_state.focus.set(false);
1904                        self.list_state.focus.set(false);
1905                        self.reaction_mode = None;
1906                        self.close_popup = None;
1907                    }
1908                    MainScreen::Details => {}
1909                    MainScreen::DetailsFullscreen => {
1910                        self.list_state.focus.set(false);
1911                        self.input_state.focus.set(false);
1912                        self.paragraph_state.focus.set(false);
1913                        self.body_paragraph_state.focus.set(true);
1914                    }
1915                    MainScreen::CreateIssue => {
1916                        self.input_state.focus.set(false);
1917                        self.list_state.focus.set(false);
1918                        self.reaction_mode = None;
1919                        self.close_popup = None;
1920                    }
1921                }
1922            }
1923            Action::Tick => {
1924                if self.is_loading_current() {
1925                    self.throbber_state.calc_next();
1926                }
1927                if self.posting {
1928                    self.post_throbber_state.calc_next();
1929                }
1930                if let Some(popup) = self.close_popup.as_mut()
1931                    && popup.loading
1932                {
1933                    popup.throbber_state.calc_next();
1934                }
1935            }
1936            _ => {}
1937        }
1938        Ok(())
1939    }
1940
1941    fn cursor(&self) -> Option<(u16, u16)> {
1942        self.input_state.screen_cursor()
1943    }
1944
1945    fn should_render(&self) -> bool {
1946        self.in_details_mode()
1947    }
1948
1949    fn is_animating(&self) -> bool {
1950        self.in_details_mode()
1951            && (self.is_loading_current()
1952                || self.posting
1953                || self.close_popup.as_ref().is_some_and(|popup| popup.loading))
1954    }
1955
1956    fn capture_focus_event(&self, event: &crossterm::event::Event) -> bool {
1957        if !self.in_details_mode() {
1958            return false;
1959        }
1960        if self.screen == MainScreen::DetailsFullscreen {
1961            return true;
1962        }
1963        if self.close_popup.is_some() {
1964            return true;
1965        }
1966        if self.input_state.is_focused() {
1967            return true;
1968        }
1969        match event {
1970            crossterm::event::Event::Key(key) => matches!(
1971                key.code,
1972                crossterm::event::KeyCode::Tab
1973                    | crossterm::event::KeyCode::BackTab
1974                    | crossterm::event::KeyCode::Char('q')
1975            ),
1976            _ => false,
1977        }
1978    }
1979    fn set_index(&mut self, index: usize) {
1980        self.index = index;
1981    }
1982
1983    fn set_global_help(&self) {
1984        if let Some(action_tx) = &self.action_tx {
1985            let _ = action_tx.try_send(Action::SetHelp(HELP));
1986        }
1987    }
1988}
1989
1990impl HasFocus for IssueConversation {
1991    fn build(&self, builder: &mut FocusBuilder) {
1992        let tag = builder.start(self);
1993        builder.widget(&self.list_state);
1994        builder.widget(&self.body_paragraph_state);
1995        match self.textbox_state {
1996            InputState::Input => builder.widget(&self.input_state),
1997            InputState::Preview => builder.widget(&self.paragraph_state),
1998        };
1999        builder.end(tag);
2000    }
2001
2002    fn focus(&self) -> FocusFlag {
2003        self.focus.clone()
2004    }
2005
2006    fn area(&self) -> Rect {
2007        self.area
2008    }
2009
2010    fn navigable(&self) -> Navigation {
2011        if self.in_details_mode() {
2012            Navigation::Regular
2013        } else {
2014            Navigation::None
2015        }
2016    }
2017}
2018
2019fn build_comment_item(
2020    author: &str,
2021    created_at: &str,
2022    preview: &str,
2023    is_self: bool,
2024    reactions: Option<&[(ReactionContent, u64)]>,
2025) -> ListItem<'static> {
2026    let author_style = if is_self {
2027        Style::new().fg(Color::Green).add_modifier(Modifier::BOLD)
2028    } else {
2029        Style::new().fg(Color::Cyan)
2030    };
2031    let header = Line::from(vec![
2032        Span::styled(author.to_string(), author_style),
2033        Span::raw("  "),
2034        Span::styled(created_at.to_string(), Style::new()),
2035    ]);
2036    let preview_line = Line::from(vec![
2037        Span::raw("  "),
2038        Span::styled(preview.to_string(), Style::new()),
2039    ]);
2040    let mut lines = vec![header, preview_line];
2041    if let Some(reactions) = reactions
2042        && !reactions.is_empty()
2043    {
2044        lines.push(build_reactions_line(reactions));
2045    }
2046    ListItem::new(lines)
2047}
2048
2049fn build_comment_preview_item(
2050    author: &str,
2051    created_at: &str,
2052    body_lines: &[Line<'static>],
2053    preview_width: usize,
2054    is_self: bool,
2055    reactions: Option<&[(ReactionContent, u64)]>,
2056) -> ListItem<'static> {
2057    let preview = extract_preview(body_lines, preview_width);
2058    build_comment_item(author, created_at, &preview, is_self, reactions)
2059}
2060
2061fn build_timeline_item(entry: &TimelineEventView, preview_width: usize) -> ListItem<'static> {
2062    let icon_style = timeline_event_style(&entry.event).add_modifier(Modifier::DIM);
2063    let dim_style = Style::new().dim();
2064    let header = Line::from(vec![
2065        Span::raw("  "),
2066        Span::styled("|", dim_style),
2067        Span::raw(" "),
2068        Span::styled(
2069            entry.icon.to_string(),
2070            icon_style.add_modifier(Modifier::BOLD),
2071        ),
2072        Span::styled(" ", dim_style),
2073        Span::styled(entry.summary.to_string(), icon_style),
2074        Span::styled("  ", dim_style),
2075        Span::styled(entry.created_at.to_string(), dim_style),
2076    ]);
2077    let details = Line::from(vec![
2078        Span::raw("  "),
2079        Span::styled("|", dim_style),
2080        Span::raw("   "),
2081        Span::styled(
2082            truncate_preview(entry.details.as_ref(), preview_width.max(12)),
2083            dim_style,
2084        ),
2085    ]);
2086    ListItem::new(vec![header, details])
2087}
2088
2089fn build_timeline_body_lines(entry: &TimelineEventView) -> Vec<Line<'static>> {
2090    vec![
2091        Line::from(vec![
2092            Span::styled("Event: ", Style::new().dim()),
2093            Span::styled(
2094                format!("{} {}", entry.icon, entry.summary),
2095                timeline_event_style(&entry.event),
2096            ),
2097        ]),
2098        Line::from(vec![
2099            Span::styled("When: ", Style::new().dim()),
2100            Span::raw(entry.created_at.to_string()),
2101        ]),
2102        Line::from(vec![
2103            Span::styled("Details: ", Style::new().dim()),
2104            Span::styled(entry.details.to_string(), Style::new().fg(Color::Gray)),
2105        ]),
2106    ]
2107}
2108
2109fn build_reactions_line(reactions: &[(ReactionContent, u64)]) -> Line<'static> {
2110    let mut ordered = reactions.iter().collect::<Vec<_>>();
2111    ordered.sort_by_key(|(content, _)| reaction_order(content));
2112
2113    let mut spans = vec![Span::raw("  ")];
2114    for (idx, (content, count)) in ordered.into_iter().enumerate() {
2115        if idx != 0 {
2116            spans.push(Span::raw("  "));
2117        }
2118        spans.push(Span::styled(
2119            reaction_label(content).to_string(),
2120            Style::new().fg(Color::Yellow),
2121        ));
2122        spans.push(Span::raw(" "));
2123        spans.push(Span::styled(count.to_string(), Style::new().dim()));
2124    }
2125    Line::from(spans)
2126}
2127
2128fn timeline_event_meta(event: &IssueEvent) -> (&'static str, &'static str) {
2129    match event {
2130        IssueEvent::Closed => ("x", "closed the issue"),
2131        IssueEvent::Reopened => ("o", "reopened the issue"),
2132        IssueEvent::Assigned => ("@", "assigned someone"),
2133        IssueEvent::Unassigned => ("@", "unassigned someone"),
2134        IssueEvent::Labeled => ("#", "added a label"),
2135        IssueEvent::Unlabeled => ("#", "removed a label"),
2136        IssueEvent::Milestoned => ("M", "set a milestone"),
2137        IssueEvent::Demilestoned => ("M", "removed the milestone"),
2138        IssueEvent::Locked => ("!", "locked the conversation"),
2139        IssueEvent::Unlocked => ("!", "unlocked the conversation"),
2140        IssueEvent::Referenced | IssueEvent::CrossReferenced => ("=>", "referenced this issue"),
2141        IssueEvent::Renamed => ("~", "renamed the title"),
2142        IssueEvent::ReviewRequested => ("R", "requested review"),
2143        IssueEvent::ReviewRequestRemoved => ("R", "removed review request"),
2144        IssueEvent::Merged => ("+", "merged"),
2145        IssueEvent::Committed => ("*", "pushed a commit"),
2146        _ => ("*", "updated the timeline"),
2147    }
2148}
2149
2150fn timeline_event_style(event: &IssueEvent) -> Style {
2151    match event {
2152        IssueEvent::Closed | IssueEvent::Locked => Style::new().fg(Color::Red),
2153        IssueEvent::Reopened | IssueEvent::Unlocked => Style::new().fg(Color::Green),
2154        IssueEvent::Labeled | IssueEvent::Unlabeled => Style::new().fg(Color::Yellow),
2155        IssueEvent::Assigned | IssueEvent::Unassigned => Style::new().fg(Color::Cyan),
2156        IssueEvent::Merged => Style::new().fg(Color::Magenta),
2157        _ => Style::new().fg(Color::Blue),
2158    }
2159}
2160
2161fn timeline_event_details(event: &TimelineEvent) -> String {
2162    match event.event {
2163        IssueEvent::Labeled | IssueEvent::Unlabeled => {
2164            if let Some(label) = event.label.as_ref() {
2165                return format!("label: {}", label.name);
2166            }
2167        }
2168        IssueEvent::Milestoned | IssueEvent::Demilestoned => {
2169            if let Some(milestone) = event.milestone.as_ref() {
2170                return format!("milestone: {}", milestone.title);
2171            }
2172        }
2173        IssueEvent::Renamed => {
2174            if let Some(rename) = event.rename.as_ref() {
2175                return format!("title: '{}' -> '{}'", rename.from, rename.to);
2176            }
2177        }
2178        IssueEvent::Assigned | IssueEvent::Unassigned => {
2179            if let Some(assignee) = event.assignee.as_ref() {
2180                return format!("assignee: @{}", assignee.login);
2181            }
2182            if let Some(assignees) = event.assignees.as_ref()
2183                && !assignees.is_empty()
2184            {
2185                let names = assignees
2186                    .iter()
2187                    .map(|a| format!("@{}", a.login))
2188                    .collect::<Vec<_>>()
2189                    .join(", ");
2190                return format!("assignees: {}", names);
2191            }
2192        }
2193        IssueEvent::ReviewRequested | IssueEvent::ReviewRequestRemoved => {
2194            if let Some(reviewer) = event.requested_reviewer.as_ref() {
2195                return format!("reviewer: @{}", reviewer.login);
2196            }
2197        }
2198        IssueEvent::Closed
2199        | IssueEvent::Merged
2200        | IssueEvent::Referenced
2201        | IssueEvent::Committed => {
2202            if let Some(reference) = format_reference_target(event) {
2203                return reference;
2204            }
2205            if let Some(commit_id) = event.commit_id.as_ref() {
2206                let short = commit_id.chars().take(8).collect::<String>();
2207                return format!("commit {}", short);
2208            }
2209            if let Some(sha) = event.sha.as_ref() {
2210                let short = sha.chars().take(8).collect::<String>();
2211                return format!("sha {}", short);
2212            }
2213        }
2214        IssueEvent::CrossReferenced | IssueEvent::Connected | IssueEvent::Disconnected => {
2215            if let Some(reference) = format_reference_target(event) {
2216                return reference;
2217            }
2218        }
2219        _ => {}
2220    }
2221
2222    if let Some(assignee) = event.assignee.as_ref() {
2223        return format!("assignee: @{}", assignee.login);
2224    }
2225    if let Some(assignees) = event.assignees.as_ref()
2226        && !assignees.is_empty()
2227    {
2228        let names = assignees
2229            .iter()
2230            .map(|a| format!("@{}", a.login))
2231            .collect::<Vec<_>>()
2232            .join(", ");
2233        return format!("assignees: {}", names);
2234    }
2235    if let Some(commit_id) = event.commit_id.as_ref() {
2236        let short = commit_id.chars().take(8).collect::<String>();
2237        return format!("commit {}", short);
2238    }
2239    if let Some(reference) = format_reference_target(event) {
2240        return reference;
2241    }
2242    if let Some(column) = event.column_name.as_ref() {
2243        if let Some(prev) = event.previous_column_name.as_ref() {
2244            return format!("moved from '{}' to '{}'", prev, column);
2245        }
2246        return format!("project column: {}", column);
2247    }
2248    if let Some(reason) = event.lock_reason.as_ref() {
2249        return format!("lock reason: {}", reason);
2250    }
2251    if let Some(message) = event.message.as_ref()
2252        && !message.trim().is_empty()
2253    {
2254        return truncate_preview(message.trim(), 96);
2255    }
2256    if let Some(body) = event.body.as_ref()
2257        && !body.trim().is_empty()
2258    {
2259        return truncate_preview(body.trim(), 96);
2260    }
2261    format!("{:?}", event.event)
2262}
2263
2264fn format_reference_target(event: &TimelineEvent) -> Option<String> {
2265    if let Some(url) = event.pull_request_url.as_ref() {
2266        if let Some(number) = extract_trailing_number(url.as_str()) {
2267            return Some(format!("pull request #{}", number));
2268        }
2269        return Some(format!("pull request {}", url));
2270    }
2271
2272    if let Some(url) = event.issue_url.as_deref() {
2273        if let Some(number) = extract_trailing_number(url) {
2274            return Some(format!("issue #{}", number));
2275        }
2276        return Some(format!("issue {}", url));
2277    }
2278
2279    None
2280}
2281
2282fn extract_trailing_number(url: &str) -> Option<u64> {
2283    let tail = url.trim_end_matches('/').rsplit('/').next()?;
2284    tail.parse::<u64>().ok()
2285}
2286
2287fn reaction_order(content: &ReactionContent) -> usize {
2288    match content {
2289        ReactionContent::PlusOne => 0,
2290        ReactionContent::Heart => 1,
2291        ReactionContent::Hooray => 2,
2292        ReactionContent::Laugh => 3,
2293        ReactionContent::Rocket => 4,
2294        ReactionContent::Eyes => 5,
2295        ReactionContent::Confused => 6,
2296        ReactionContent::MinusOne => 7,
2297        _ => usize::MAX,
2298    }
2299}
2300
2301fn reaction_label(content: &ReactionContent) -> &'static str {
2302    match content {
2303        ReactionContent::PlusOne => "+1",
2304        ReactionContent::MinusOne => "-1",
2305        ReactionContent::Laugh => "laugh",
2306        ReactionContent::Confused => "confused",
2307        ReactionContent::Heart => "heart",
2308        ReactionContent::Hooray => "hooray",
2309        ReactionContent::Rocket => "rocket",
2310        ReactionContent::Eyes => "eyes",
2311        _ => "other",
2312    }
2313}
2314
2315fn reaction_add_options() -> [ReactionContent; 8] {
2316    [
2317        ReactionContent::PlusOne,
2318        ReactionContent::Heart,
2319        ReactionContent::Hooray,
2320        ReactionContent::Laugh,
2321        ReactionContent::Rocket,
2322        ReactionContent::Eyes,
2323        ReactionContent::Confused,
2324        ReactionContent::MinusOne,
2325    ]
2326}
2327
2328fn format_reaction_picker(selected: usize, options: &[ReactionContent]) -> String {
2329    let mut out = String::new();
2330    let mut bracket_start = None;
2331    let mut bracket_end = None;
2332    const TOTAL_WIDTH: usize = 20;
2333    for (idx, content) in options.iter().enumerate() {
2334        if idx > 0 {
2335            out.push(' ');
2336        }
2337        let label = reaction_label(content);
2338        if idx == selected {
2339            bracket_start = Some(out.len());
2340            out.push('[');
2341            out.push_str(label);
2342            bracket_end = Some(out.len());
2343            out.push(']');
2344        } else {
2345            out.push_str(label);
2346        }
2347    }
2348    if let (Some(start), Some(end)) = (bracket_start, bracket_end) {
2349        let padding = TOTAL_WIDTH.saturating_sub(end - start + 1);
2350        let left_padding = padding / 2;
2351        let left_start = start.saturating_sub(left_padding);
2352        let right_padding = padding - left_padding;
2353        let right_end = (end + right_padding).min(out.len());
2354        return out[left_start..right_end].to_string();
2355    }
2356    out
2357}
2358
2359fn to_reaction_snapshot<I>(
2360    reactions: I,
2361    current_user: &str,
2362) -> (Vec<(ReactionContent, u64)>, Vec<ReactionContent>)
2363where
2364    I: IntoIterator<Item = octocrab::models::reactions::Reaction>,
2365{
2366    let mut mine = Vec::new();
2367    let counts = reactions
2368        .into_iter()
2369        .fold(HashMap::new(), |mut acc, reaction| {
2370            if reaction.user.login.eq_ignore_ascii_case(current_user) {
2371                mine.push(reaction.content.clone());
2372            }
2373            *acc.entry(reaction.content).or_insert(0) += 1_u64;
2374            acc
2375        });
2376    mine.sort_by_key(reaction_order);
2377    mine.dedup();
2378    (counts.into_iter().collect::<Vec<_>>(), mine)
2379}
2380
2381fn extract_preview(lines: &[Line<'static>], preview_width: usize) -> String {
2382    for line in lines {
2383        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
2384        let trimmed = text.trim();
2385        if !trimmed.is_empty() {
2386            return truncate_preview(trimmed, preview_width.max(8));
2387        }
2388    }
2389    "(empty)".to_string()
2390}
2391
2392fn truncate_preview(input: &str, max_width: usize) -> String {
2393    if display_width(input) <= max_width {
2394        return input.to_string();
2395    }
2396    let mut out = String::new();
2397    for ch in input.chars() {
2398        let mut candidate = out.clone();
2399        candidate.push(ch);
2400        if display_width(&candidate) + 3 > max_width {
2401            break;
2402        }
2403        out.push(ch);
2404    }
2405    out.push_str("...");
2406    out
2407}
2408
2409pub(crate) fn render_markdown_lines(text: &str, width: usize, indent: usize) -> Vec<Line<'static>> {
2410    render_markdown(text, width, indent).lines
2411}
2412
2413fn render_markdown(text: &str, width: usize, indent: usize) -> MarkdownRender {
2414    let mut renderer = MarkdownRenderer::new(width, indent);
2415    let options = Options::ENABLE_GFM
2416        | Options::ENABLE_STRIKETHROUGH
2417        | Options::ENABLE_TASKLISTS
2418        | Options::ENABLE_TABLES
2419        | Options::ENABLE_FOOTNOTES
2420        | Options::ENABLE_SUPERSCRIPT
2421        | Options::ENABLE_SUBSCRIPT
2422        | Options::ENABLE_MATH;
2423    let parser = Parser::new_ext(text, options);
2424    let parser = TextMergeStream::new(parser);
2425    for event in parser {
2426        match event {
2427            MdEvent::Start(tag) => renderer.start_tag(tag),
2428            MdEvent::End(tag) => renderer.end_tag(tag),
2429            MdEvent::Text(text) => renderer.text(&text),
2430            MdEvent::Code(text) => renderer.inline_code(&text),
2431            MdEvent::InlineMath(text) | MdEvent::DisplayMath(text) => renderer.inline_math(&text),
2432            MdEvent::SoftBreak => renderer.soft_break(),
2433            MdEvent::HardBreak => renderer.hard_break(),
2434            MdEvent::Html(text) | MdEvent::InlineHtml(text) => renderer.text(&text),
2435            MdEvent::Rule => renderer.rule(),
2436            MdEvent::TaskListMarker(checked) => renderer.task_list_marker(checked),
2437            _ => {}
2438        }
2439    }
2440    renderer.finish()
2441}
2442
2443struct MarkdownRenderer {
2444    lines: Vec<Line<'static>>,
2445    links: Vec<RenderedLink>,
2446    current_line: Vec<Span<'static>>,
2447    current_width: usize,
2448    max_width: usize,
2449    indent: usize,
2450    style_stack: Vec<Style>,
2451    current_style: Style,
2452    in_block_quote: bool,
2453    block_quote_style: Option<AdmonitionStyle>,
2454    block_quote_title_pending: bool,
2455    in_code_block: bool,
2456    code_block_lang: Option<String>,
2457    code_block_buf: String,
2458    list_prefix: Option<String>,
2459    pending_space: bool,
2460    active_link_url: Option<String>,
2461}
2462
2463#[derive(Clone, Copy)]
2464struct AdmonitionStyle {
2465    marker: &'static str,
2466    default_title: &'static str,
2467    border_color: Color,
2468    title_style: Style,
2469}
2470
2471impl AdmonitionStyle {
2472    fn from_block_quote_kind(kind: BlockQuoteKind) -> Option<Self> {
2473        match kind {
2474            BlockQuoteKind::Note => Some(Self {
2475                marker: "NOTE",
2476                default_title: "Note",
2477                border_color: Color::Blue,
2478                title_style: Style::new().fg(Color::Blue).add_modifier(Modifier::BOLD),
2479            }),
2480            BlockQuoteKind::Tip => Some(Self {
2481                marker: "TIP",
2482                default_title: "Tip",
2483                border_color: Color::Green,
2484                title_style: Style::new().fg(Color::Green).add_modifier(Modifier::BOLD),
2485            }),
2486            BlockQuoteKind::Important => Some(Self {
2487                marker: "IMPORTANT",
2488                default_title: "Important",
2489                border_color: Color::Cyan,
2490                title_style: Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
2491            }),
2492            BlockQuoteKind::Warning => Some(Self {
2493                marker: "WARNING",
2494                default_title: "Warning",
2495                border_color: Color::Yellow,
2496                title_style: Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD),
2497            }),
2498            BlockQuoteKind::Caution => Some(Self {
2499                marker: "CAUTION",
2500                default_title: "Caution",
2501                border_color: Color::Red,
2502                title_style: Style::new().fg(Color::Red).add_modifier(Modifier::BOLD),
2503            }),
2504        }
2505    }
2506}
2507
2508impl MarkdownRenderer {
2509    fn new(max_width: usize, indent: usize) -> Self {
2510        Self {
2511            lines: Vec::new(),
2512            links: Vec::new(),
2513            current_line: Vec::new(),
2514            current_width: 0,
2515            max_width: max_width.max(10),
2516            indent,
2517            style_stack: Vec::new(),
2518            current_style: Style::new(),
2519            in_block_quote: false,
2520            block_quote_style: None,
2521            block_quote_title_pending: false,
2522            in_code_block: false,
2523            code_block_lang: None,
2524            code_block_buf: String::new(),
2525            list_prefix: None,
2526            pending_space: false,
2527            active_link_url: None,
2528        }
2529    }
2530
2531    fn start_tag(&mut self, tag: Tag) {
2532        match tag {
2533            Tag::Emphasis => self.push_style(Style::new().add_modifier(Modifier::ITALIC)),
2534            Tag::Strong => self.push_style(Style::new().add_modifier(Modifier::BOLD)),
2535            Tag::Strikethrough => self.push_style(Style::new().add_modifier(Modifier::CROSSED_OUT)),
2536            Tag::Superscript | Tag::Subscript => {
2537                self.push_style(Style::new().add_modifier(Modifier::ITALIC))
2538            }
2539            Tag::Link { dest_url, .. } => {
2540                self.active_link_url = Some(dest_url.to_string());
2541                self.push_style(
2542                    Style::new()
2543                        .fg(Color::Blue)
2544                        .add_modifier(Modifier::UNDERLINED),
2545                );
2546            }
2547            Tag::Heading { .. } => {
2548                self.push_style(Style::new().add_modifier(Modifier::BOLD));
2549            }
2550            Tag::BlockQuote(kind) => {
2551                self.flush_line();
2552                self.in_block_quote = true;
2553                self.block_quote_style = kind.and_then(AdmonitionStyle::from_block_quote_kind);
2554                self.block_quote_title_pending = self.block_quote_style.is_some();
2555            }
2556            Tag::CodeBlock(kind) => {
2557                self.ensure_admonition_header();
2558                self.flush_line();
2559                self.in_code_block = true;
2560                self.code_block_lang = code_block_kind_lang(kind);
2561                self.code_block_buf.clear();
2562            }
2563            Tag::Item => {
2564                self.flush_line();
2565                self.list_prefix = Some("• ".to_string());
2566            }
2567            _ => {}
2568        }
2569    }
2570
2571    fn end_tag(&mut self, tag: TagEnd) {
2572        match tag {
2573            TagEnd::Emphasis
2574            | TagEnd::Strong
2575            | TagEnd::Strikethrough
2576            | TagEnd::Superscript
2577            | TagEnd::Subscript
2578            | TagEnd::Link => {
2579                if matches!(tag, TagEnd::Link) {
2580                    self.active_link_url = None;
2581                }
2582                self.pop_style();
2583            }
2584            TagEnd::Heading(_) => {
2585                self.pop_style();
2586                self.flush_line();
2587            }
2588            TagEnd::BlockQuote(_) => {
2589                self.flush_line();
2590                self.in_block_quote = false;
2591                self.block_quote_style = None;
2592                self.block_quote_title_pending = false;
2593                self.push_blank_line();
2594            }
2595            TagEnd::CodeBlock => {
2596                self.render_code_block();
2597                self.flush_line();
2598                self.in_code_block = false;
2599                self.code_block_lang = None;
2600                self.code_block_buf.clear();
2601                self.push_blank_line();
2602            }
2603            TagEnd::Item => {
2604                self.flush_line();
2605                self.list_prefix = None;
2606            }
2607            TagEnd::Paragraph => {
2608                self.flush_line();
2609                self.push_blank_line();
2610            }
2611            _ => {}
2612        }
2613    }
2614
2615    fn text(&mut self, text: &str) {
2616        if self.in_block_quote && self.block_quote_title_pending {
2617            if let Some(style) = self.block_quote_style
2618                && let Some(title) = extract_admonition_title(text, style.marker)
2619            {
2620                let title = if title.is_empty() {
2621                    style.default_title
2622                } else {
2623                    title
2624                };
2625                self.push_admonition_header(title, style);
2626                self.block_quote_title_pending = false;
2627                return;
2628            }
2629            self.ensure_admonition_header();
2630        }
2631        if self.in_code_block {
2632            self.code_block_text(text);
2633        } else {
2634            let style = self.current_style;
2635            self.push_text(text, style);
2636        }
2637    }
2638
2639    fn inline_code(&mut self, text: &str) {
2640        self.ensure_admonition_header();
2641        let style = self
2642            .current_style
2643            .patch(Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD));
2644        self.push_text(text, style);
2645    }
2646
2647    fn inline_math(&mut self, text: &str) {
2648        self.ensure_admonition_header();
2649        let style = self.current_style.patch(
2650            Style::new()
2651                .fg(Color::LightMagenta)
2652                .add_modifier(Modifier::ITALIC),
2653        );
2654        self.push_text(text, style);
2655    }
2656
2657    fn soft_break(&mut self) {
2658        self.ensure_admonition_header();
2659        if self.in_code_block {
2660            self.code_block_buf.push('\n');
2661        } else {
2662            self.pending_space = true;
2663        }
2664    }
2665
2666    fn hard_break(&mut self) {
2667        self.ensure_admonition_header();
2668        if self.in_code_block {
2669            self.code_block_buf.push('\n');
2670            return;
2671        }
2672        self.flush_line();
2673    }
2674
2675    fn task_list_marker(&mut self, checked: bool) {
2676        self.ensure_admonition_header();
2677        let marker = if checked { "[x] " } else { "[ ] " };
2678        self.push_text(marker, self.current_style);
2679    }
2680
2681    fn rule(&mut self) {
2682        self.flush_line();
2683        self.start_line();
2684        let width = self.max_width.saturating_sub(self.prefix_width()).max(8);
2685        let bar = "─".repeat(width);
2686        self.current_line
2687            .push(Span::styled(bar.clone(), Style::new().fg(Color::DarkGray)));
2688        self.current_width += display_width(&bar);
2689        self.flush_line();
2690        self.push_blank_line();
2691    }
2692
2693    fn push_text(&mut self, text: &str, style: Style) {
2694        let mut buffer = String::new();
2695        for ch in text.chars() {
2696            if ch == '\n' {
2697                if !buffer.is_empty() {
2698                    self.push_word(&buffer, style);
2699                    buffer.clear();
2700                }
2701                self.flush_line();
2702                continue;
2703            }
2704            if ch.is_whitespace() {
2705                if !buffer.is_empty() {
2706                    self.push_word(&buffer, style);
2707                    buffer.clear();
2708                }
2709                self.pending_space = true;
2710            } else {
2711                buffer.push(ch);
2712            }
2713        }
2714        if !buffer.is_empty() {
2715            self.push_word(&buffer, style);
2716        }
2717    }
2718
2719    fn push_word(&mut self, word: &str, style: Style) {
2720        let prefix_width = self.prefix_width();
2721        let max_width = self.max_width;
2722        let word_width = display_width(word);
2723        let space_width = if self.pending_space && self.current_width > prefix_width {
2724            1
2725        } else {
2726            0
2727        };
2728
2729        if word_width > max_width.saturating_sub(prefix_width) {
2730            self.push_long_word(word, style);
2731            self.pending_space = false;
2732            return;
2733        }
2734
2735        if self.current_line.is_empty() {
2736            self.start_line();
2737        }
2738
2739        if self.current_width + space_width + word_width > max_width
2740            && self.current_width > prefix_width
2741        {
2742            self.flush_line();
2743            self.start_line();
2744        }
2745
2746        if self.pending_space && self.current_width > prefix_width {
2747            let space_col = self.current_width;
2748            self.current_line.push(Span::raw(" "));
2749            self.current_width += 1;
2750            if self.should_attach_space_to_active_link(space_col) {
2751                self.push_link_segment(" ", space_col, 1);
2752            }
2753        }
2754        self.pending_space = false;
2755
2756        let link_start_col = self.current_width;
2757        self.current_line
2758            .push(Span::styled(word.to_string(), style));
2759        self.current_width += word_width;
2760        self.push_link_segment(word, link_start_col, word_width);
2761    }
2762
2763    fn push_long_word(&mut self, word: &str, style: Style) {
2764        let available = self.max_width.saturating_sub(self.prefix_width()).max(1);
2765        let wrapped = textwrap::wrap(word, textwrap::Options::new(available).break_words(true));
2766        for (idx, part) in wrapped.iter().enumerate() {
2767            if idx > 0 {
2768                self.flush_line();
2769            }
2770            if self.current_line.is_empty() {
2771                self.start_line();
2772            }
2773            let link_start_col = self.current_width;
2774            let part_width = display_width(part);
2775            self.current_line
2776                .push(Span::styled(part.to_string(), style));
2777            self.current_width += part_width;
2778            self.push_link_segment(part, link_start_col, part_width);
2779        }
2780    }
2781
2782    fn push_link_segment(&mut self, label: &str, col: usize, width: usize) {
2783        let Some(url) = self.active_link_url.as_ref() else {
2784            return;
2785        };
2786        if label.is_empty() || width == 0 {
2787            return;
2788        }
2789
2790        let line = self.current_line_index();
2791        if let Some(last) = self.links.last_mut()
2792            && last.url == *url
2793            && last.line == line
2794            && last.col + last.width == col
2795        {
2796            last.label.push_str(label);
2797            last.width += width;
2798            return;
2799        }
2800
2801        self.links.push(RenderedLink {
2802            line,
2803            col,
2804            label: label.to_string(),
2805            url: url.clone(),
2806            width,
2807        });
2808    }
2809
2810    fn should_attach_space_to_active_link(&self, space_col: usize) -> bool {
2811        let Some(url) = self.active_link_url.as_ref() else {
2812            return false;
2813        };
2814        let line = self.current_line_index();
2815        self.links.last().is_some_and(|last| {
2816            last.url == *url && last.line == line && last.col + last.width == space_col
2817        })
2818    }
2819
2820    fn current_line_index(&self) -> usize {
2821        self.lines.len()
2822    }
2823
2824    fn code_block_text(&mut self, text: &str) {
2825        self.code_block_buf.push_str(text);
2826    }
2827
2828    fn render_code_block(&mut self) {
2829        if self.code_block_buf.is_empty() {
2830            return;
2831        }
2832
2833        let code = std::mem::take(&mut self.code_block_buf);
2834        let assets = syntect_assets();
2835        let syntax = resolve_syntax(&assets.syntaxes, self.code_block_lang.as_deref());
2836        let mut highlighter = HighlightLines::new(syntax, &assets.theme);
2837        let fallback_style = Style::new().light_yellow();
2838
2839        for raw_line in code.split('\n') {
2840            self.flush_line();
2841            self.start_line();
2842            match highlighter.highlight_line(raw_line, &assets.syntaxes) {
2843                Ok(regions) => {
2844                    for (syn_style, fragment) in regions {
2845                        if fragment.is_empty() {
2846                            continue;
2847                        }
2848                        self.current_line.push(Span::styled(
2849                            fragment.to_string(),
2850                            syntect_style_to_ratatui(syn_style),
2851                        ));
2852                        self.current_width += display_width(fragment);
2853                    }
2854                }
2855                Err(_) => {
2856                    if !raw_line.is_empty() {
2857                        self.current_line
2858                            .push(Span::styled(raw_line.to_string(), fallback_style));
2859                        self.current_width += display_width(raw_line);
2860                    }
2861                }
2862            }
2863            self.flush_line();
2864        }
2865    }
2866
2867    fn start_line(&mut self) {
2868        if !self.current_line.is_empty() {
2869            return;
2870        }
2871        if self.indent > 0 {
2872            let indent = " ".repeat(self.indent);
2873            self.current_width += self.indent;
2874            self.current_line.push(Span::raw(indent));
2875        }
2876        if self.in_block_quote {
2877            self.current_width += 2;
2878            let border_style = self
2879                .block_quote_style
2880                .map(|s| Style::new().fg(s.border_color))
2881                .unwrap_or_else(|| Style::new().fg(Color::DarkGray));
2882            self.current_line.push(Span::styled("│ ", border_style));
2883        }
2884        if let Some(prefix) = &self.list_prefix {
2885            self.current_width += display_width(prefix);
2886            self.current_line.push(Span::raw(prefix.clone()));
2887        }
2888    }
2889
2890    fn prefix_width(&self) -> usize {
2891        let mut width = self.indent;
2892        if self.in_block_quote {
2893            width += 2;
2894        }
2895        if let Some(prefix) = &self.list_prefix {
2896            width += display_width(prefix);
2897        }
2898        width
2899    }
2900
2901    fn flush_line(&mut self) {
2902        if self.current_line.is_empty() {
2903            self.pending_space = false;
2904            return;
2905        }
2906        let line = Line::from(std::mem::take(&mut self.current_line));
2907        self.lines.push(line);
2908        self.current_width = 0;
2909        self.pending_space = false;
2910    }
2911
2912    fn push_blank_line(&mut self) {
2913        if self.lines.last().is_some_and(|line| line.spans.is_empty()) {
2914            return;
2915        }
2916        self.lines.push(Line::from(Vec::<Span<'static>>::new()));
2917    }
2918
2919    fn push_style(&mut self, style: Style) {
2920        self.style_stack.push(self.current_style);
2921        self.current_style = self.current_style.patch(style);
2922    }
2923
2924    fn pop_style(&mut self) {
2925        if let Some(prev) = self.style_stack.pop() {
2926            self.current_style = prev;
2927        }
2928    }
2929
2930    fn finish(mut self) -> MarkdownRender {
2931        self.flush_line();
2932        while self.lines.last().is_some_and(|line| line.spans.is_empty()) {
2933            self.lines.pop();
2934        }
2935        if self.lines.is_empty() {
2936            self.lines.push(Line::from(vec![Span::raw("")]));
2937        }
2938        MarkdownRender {
2939            lines: self.lines,
2940            links: self.links,
2941        }
2942    }
2943
2944    fn ensure_admonition_header(&mut self) {
2945        if !self.block_quote_title_pending {
2946            return;
2947        }
2948        if let Some(style) = self.block_quote_style {
2949            self.push_admonition_header(style.default_title, style);
2950        }
2951        self.block_quote_title_pending = false;
2952    }
2953
2954    fn push_admonition_header(&mut self, title: &str, style: AdmonitionStyle) {
2955        self.flush_line();
2956        self.start_line();
2957        self.current_line
2958            .push(Span::styled(title.to_string(), style.title_style));
2959        self.current_width += display_width(title);
2960        self.flush_line();
2961    }
2962}
2963
2964fn extract_admonition_title<'a>(text: &'a str, marker: &str) -> Option<&'a str> {
2965    let trimmed = text.trim_start();
2966    let min_len = marker.len() + 3;
2967    if trimmed.len() < min_len {
2968        return None;
2969    }
2970    let bytes = trimmed.as_bytes();
2971    if bytes[0] != b'[' || bytes[1] != b'!' {
2972        return None;
2973    }
2974    let marker_end = 2 + marker.len();
2975    if bytes.get(marker_end) != Some(&b']') {
2976        return None;
2977    }
2978    if !trimmed[2..marker_end].eq_ignore_ascii_case(marker) {
2979        return None;
2980    }
2981    Some(trimmed[marker_end + 1..].trim())
2982}
2983
2984fn code_block_kind_lang(kind: CodeBlockKind<'_>) -> Option<String> {
2985    match kind {
2986        CodeBlockKind::Indented => None,
2987        CodeBlockKind::Fenced(info) => parse_fenced_language(&info).map(|lang| lang.to_lowercase()),
2988    }
2989}
2990
2991fn parse_fenced_language(info: &str) -> Option<&str> {
2992    let token = info
2993        .split_ascii_whitespace()
2994        .next()
2995        .unwrap_or_default()
2996        .split(',')
2997        .next()
2998        .unwrap_or_default()
2999        .trim_matches(|c| c == '{' || c == '}');
3000    let token = token.strip_prefix('.').unwrap_or(token);
3001    if token.is_empty() { None } else { Some(token) }
3002}
3003
3004fn resolve_syntax<'a>(syntaxes: &'a SyntaxSet, lang: Option<&str>) -> &'a SyntaxReference {
3005    if let Some(lang) = lang {
3006        if let Some(syntax) = syntaxes.find_syntax_by_token(lang) {
3007            return syntax;
3008        }
3009        if let Some(stripped) = lang.strip_prefix("language-")
3010            && let Some(syntax) = syntaxes.find_syntax_by_token(stripped)
3011        {
3012            return syntax;
3013        }
3014        if let Some(syntax) = syntaxes.find_syntax_by_extension(lang) {
3015            return syntax;
3016        }
3017    }
3018    syntaxes.find_syntax_plain_text()
3019}
3020
3021fn syntect_style_to_ratatui(style: syntect::highlighting::Style) -> Style {
3022    let mut out = Style::new().fg(Color::Rgb(
3023        style.foreground.r,
3024        style.foreground.g,
3025        style.foreground.b,
3026    ));
3027    if style.font_style.contains(FontStyle::BOLD) {
3028        out = out.add_modifier(Modifier::BOLD);
3029    }
3030    if style.font_style.contains(FontStyle::ITALIC) {
3031        out = out.add_modifier(Modifier::ITALIC);
3032    }
3033    if style.font_style.contains(FontStyle::UNDERLINE) {
3034        out = out.add_modifier(Modifier::UNDERLINED);
3035    }
3036    out
3037}
3038
3039#[cfg(test)]
3040mod tests {
3041    use super::render_markdown;
3042
3043    fn line_text(rendered: &super::MarkdownRender, idx: usize) -> String {
3044        rendered.lines[idx]
3045            .spans
3046            .iter()
3047            .map(|s| s.content.as_ref())
3048            .collect()
3049    }
3050
3051    #[test]
3052    fn extracts_link_segments_with_urls() {
3053        let rendered = render_markdown("Go to [ratatui docs](https://github.com/ratatui/).", 80, 0);
3054
3055        assert!(!rendered.links.is_empty());
3056        assert!(
3057            rendered
3058                .links
3059                .iter()
3060                .all(|link| link.url == "https://github.com/ratatui/")
3061        );
3062    }
3063
3064    #[test]
3065    fn wraps_long_links_into_multiple_segments() {
3066        let rendered = render_markdown("[A very long linked label](https://example.com)", 12, 2);
3067
3068        assert!(rendered.links.len() >= 2);
3069    }
3070
3071    #[test]
3072    fn keeps_spaces_around_plain_links() {
3073        let rendered = render_markdown("left https://google.com right", 80, 0);
3074
3075        assert_eq!(line_text(&rendered, 0), "left https://google.com right");
3076        assert!(
3077            rendered
3078                .links
3079                .iter()
3080                .all(|link| !link.label.starts_with(' ') && !link.label.ends_with(' '))
3081        );
3082    }
3083}