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