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