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