Skip to main content

gitv_tui/ui/components/
issue_conversation.rs

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