1use std::sync::Arc;
2
3use async_trait::async_trait;
4use octocrab::models::IssueState;
5use ratatui::{
6 buffer::Buffer,
7 layout::{Constraint, Direction, Layout as RtLayout, Rect},
8 prelude::Widget,
9 style::Style,
10 text::{Line, Span, Text},
11 widgets::{Block, Paragraph, Wrap},
12};
13use ratatui_macros::line;
14
15use crate::{
16 errors::AppError,
17 ui::{Action, AppState, components::DumbComponent, layout::Layout},
18};
19use hyperrat::Link;
20
21#[derive(Debug, Clone)]
22pub struct IssuePreviewSeed {
23 pub number: u64,
24 pub state: IssueState,
25 pub author: Arc<str>,
26 pub created_at: Arc<str>,
27 pub updated_at: Arc<str>,
28 pub comments: u32,
29 pub assignees: Vec<Arc<str>>,
30 pub milestone: Option<Arc<str>>,
31 pub is_pull_request: bool,
32 pub pull_request_url: Option<Arc<str>>,
33}
34
35impl IssuePreviewSeed {
36 pub fn from_issue(issue: &octocrab::models::issues::Issue) -> Self {
37 let assignees = issue
38 .assignees
39 .iter()
40 .map(|a| Arc::<str>::from(a.login.as_str()))
41 .collect();
42 let milestone = issue
43 .milestone
44 .as_ref()
45 .map(|m| Arc::<str>::from(m.title.as_str()));
46 Self {
47 number: issue.number,
48 state: issue.state.clone(),
49 author: Arc::<str>::from(issue.user.login.as_str()),
50 created_at: Arc::<str>::from(issue.created_at.format("%Y-%m-%d %H:%M").to_string()),
51 updated_at: Arc::<str>::from(issue.updated_at.format("%Y-%m-%d %H:%M").to_string()),
52 comments: issue.comments,
53 assignees,
54 milestone,
55 is_pull_request: issue.pull_request.is_some(),
56 pull_request_url: issue
57 .pull_request
58 .as_ref()
59 .map(|pr| Arc::<str>::from(pr.html_url.as_str())),
60 }
61 }
62}
63
64#[derive(Debug, Clone)]
65pub struct PrSummary {
66 pub number: u64,
67 pub title: Arc<str>,
68 pub state: IssueState,
69}
70
71pub struct IssuePreview {
72 current: Option<IssuePreviewSeed>,
73 action_tx: Option<tokio::sync::mpsc::Sender<Action>>,
74 area: Rect,
75}
76
77impl IssuePreview {
78 pub fn new(_: AppState) -> Self {
79 Self {
80 current: None,
81 action_tx: None,
82 area: Rect::default(),
83 }
84 }
85
86 pub fn render(&mut self, area: Layout, buf: &mut Buffer) {
87 self.area = area.issue_preview;
88 let block = Block::bordered()
89 .border_type(ratatui::widgets::BorderType::Rounded)
90 .title("Issue Info");
91
92 let inner = block.inner(area.issue_preview);
93 block.render(area.issue_preview, buf);
94
95 let mut sections = vec![Constraint::Min(1)];
96 if self
97 .current
98 .as_ref()
99 .and_then(|seed| seed.pull_request_url.as_ref())
100 .is_some()
101 {
102 sections.push(Constraint::Length(1));
103 }
104 let split = RtLayout::default()
105 .direction(Direction::Vertical)
106 .constraints(sections)
107 .split(inner);
108
109 let text = self.build_text();
110 let widget = Paragraph::new(text).wrap(Wrap { trim: true });
111 widget.render(split[0], buf);
112
113 if let Some(seed) = &self.current
114 && let Some(pr_url) = &seed.pull_request_url
115 && split.len() > 1
116 {
117 let label = format!("Open #{} on GitHub", seed.number);
118 Link::new(label, pr_url.as_ref())
119 .fallback_suffix(" (link)")
120 .render(split[1], buf);
121 }
122 }
123
124 fn build_text(&self) -> Text<'_> {
125 let mut lines: Vec<Line<'_>> = Vec::new();
126 let label_style = Style::new().dim();
127
128 let Some(seed) = &self.current else {
129 lines.push(line![Span::styled(
130 "Select an issue to see details.",
131 Style::new().dim()
132 )]);
133 return Text::from(lines);
134 };
135
136 let state_style = match seed.state {
137 IssueState::Open => Style::new().green(),
138 IssueState::Closed => Style::new().magenta(),
139 _ => Style::new().cyan(),
140 };
141
142 let kind = if seed.is_pull_request {
143 "Pull Request"
144 } else {
145 "Issue"
146 };
147 lines.push(Line::from(vec![
148 Span::styled("Type: ", label_style),
149 Span::styled(kind, Style::new().cyan()),
150 ]));
151 lines.push(Line::from(vec![
152 Span::styled("State: ", label_style),
153 Span::styled(format!("{:?}", seed.state), state_style),
154 ]));
155 lines.push(Line::from(vec![
156 Span::styled("Author: ", label_style),
157 Span::styled(seed.author.as_ref(), Style::new().cyan()),
158 ]));
159 lines.push(Line::from(vec![
160 Span::styled("Created: ", label_style),
161 Span::styled(seed.created_at.as_ref(), Style::new().dim()),
162 ]));
163 lines.push(Line::from(vec![
164 Span::styled("Updated: ", label_style),
165 Span::styled(seed.updated_at.as_ref(), Style::new().dim()),
166 ]));
167 lines.push(Line::from(vec![
168 Span::styled("Comments: ", label_style),
169 Span::styled(seed.comments.to_string(), Style::new().yellow()),
170 ]));
171
172 let assignees = summarize_list(&seed.assignees, 3);
173 lines.push(Line::from(vec![
174 Span::styled("Assignees: ", label_style),
175 Span::styled(assignees, Style::new().white()),
176 ]));
177
178 let milestone = seed
179 .milestone
180 .as_ref()
181 .map(|m| m.as_ref())
182 .unwrap_or("None");
183 lines.push(Line::from(vec![
184 Span::styled("Milestone: ", label_style),
185 Span::styled(milestone, Style::new().light_blue()),
186 ]));
187
188 if seed.is_pull_request && matches!(seed.state, IssueState::Open) {
189 lines.push(Line::from(vec![Span::styled("Open PRs:", label_style)]));
190 lines.push(Line::from(vec![
191 Span::raw(" #"),
192 Span::styled(seed.number.to_string(), Style::new().yellow()),
193 Span::raw(" "),
194 Span::styled("(this issue is a PR)", Style::new().green()),
195 ]));
196 } else {
197 lines.push(Line::from(vec![
198 Span::styled("Open PRs: ", label_style),
199 Span::styled("None", Style::new().dim()),
200 ]));
201 }
202
203 Text::from(lines)
204 }
205}
206
207#[async_trait(?Send)]
208impl DumbComponent for IssuePreview {
209 fn render(&mut self, area: Layout, buf: &mut Buffer) {
210 self.render(area, buf);
211 }
212
213 fn register_action_tx(&mut self, action_tx: tokio::sync::mpsc::Sender<Action>) {
214 self.action_tx = Some(action_tx);
215 }
216
217 async fn handle_event(&mut self, event: Action) -> Result<(), AppError> {
218 if let Action::SelectedIssuePreview { seed } = event {
219 self.current = Some(seed);
220 }
221 Ok(())
222 }
223}
224
225fn summarize_list(items: &[Arc<str>], max: usize) -> String {
226 if items.is_empty() {
227 return "None".to_string();
228 }
229 if items.len() <= max {
230 return items
231 .iter()
232 .map(|s| s.as_ref())
233 .collect::<Vec<_>>()
234 .join(", ");
235 }
236 let shown = items
237 .iter()
238 .take(max)
239 .map(|s| s.as_ref())
240 .collect::<Vec<_>>()
241 .join(", ");
242 format!("{shown} +{} more", items.len() - max)
243}