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