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), ¤t_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(¤t_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(¤t_user)
1036 {
1037 removed = true;
1038 None
1039 } else {
1040 Some(reaction)
1041 }
1042 }),
1043 ¤t_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 ¤t_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("ed);
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}