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::{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}