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