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