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