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