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