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