Skip to main content

gitv_tui/ui/components/
issue_detail.rs

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}