bitbucket_cli/tui/views/
issues.rs

1/// Issue browser view
2use ratatui::{
3    layout::{Constraint, Direction, Layout, Rect},
4    style::{Color, Modifier, Style},
5    text::{Line, Span},
6    widgets::{Block, Borders, List, ListItem, Paragraph},
7    Frame,
8};
9
10use crate::models::{Issue, IssueKind, IssuePriority, IssueState};
11use crate::tui::app::App;
12
13/// Issue list view
14pub struct IssuesView;
15
16impl IssuesView {
17    /// Render the issue browser
18    pub fn render(f: &mut Frame, app: &App, area: Rect) {
19        let chunks = Layout::default()
20            .direction(Direction::Horizontal)
21            .constraints([
22                Constraint::Percentage(60), // List
23                Constraint::Percentage(40), // Details
24            ])
25            .split(area);
26
27        Self::render_list(f, app, chunks[0]);
28        Self::render_details(f, app, chunks[1]);
29    }
30
31    fn render_list(f: &mut Frame, app: &App, area: Rect) {
32        let items: Vec<ListItem> = if app.issues.is_empty() {
33            vec![
34                ListItem::new(Line::from(Span::styled(
35                    "No issues loaded",
36                    Style::default().fg(Color::DarkGray),
37                ))),
38                ListItem::new(Line::from("")),
39                ListItem::new(Line::from(Span::styled(
40                    "Press 'r' to refresh",
41                    Style::default().fg(Color::Yellow),
42                ))),
43            ]
44        } else {
45            app.issues
46                .iter()
47                .map(|issue| Self::issue_to_list_item(issue))
48                .collect()
49        };
50
51        let list = List::new(items)
52            .block(Block::default().borders(Borders::ALL).title(" Issues "))
53            .highlight_style(
54                Style::default()
55                    .bg(Color::DarkGray)
56                    .add_modifier(Modifier::BOLD),
57            )
58            .highlight_symbol("▶ ");
59
60        let mut state = ratatui::widgets::ListState::default();
61        if !app.issues.is_empty() {
62            state.select(Some(app.view_state.selected_index));
63        }
64        f.render_stateful_widget(list, area, &mut state);
65    }
66
67    fn render_details(f: &mut Frame, app: &App, area: Rect) {
68        let content = if let Some(issue) = app.issues.get(app.view_state.selected_index) {
69            let state_color = Self::state_color(&issue.state);
70            let priority_color = Self::priority_color(&issue.priority);
71
72            vec![
73                Line::from(vec![
74                    Span::styled(
75                        format!("#{} ", issue.id),
76                        Style::default().fg(Color::DarkGray),
77                    ),
78                    Span::styled(&issue.title, Style::default().add_modifier(Modifier::BOLD)),
79                ]),
80                Line::from(""),
81                Line::from(vec![
82                    Span::styled("Status: ", Style::default().fg(Color::DarkGray)),
83                    Span::styled(format!("{}", issue.state), Style::default().fg(state_color)),
84                ]),
85                Line::from(vec![
86                    Span::styled("Type: ", Style::default().fg(Color::DarkGray)),
87                    Span::raw(format!("{}", issue.kind)),
88                ]),
89                Line::from(vec![
90                    Span::styled("Priority: ", Style::default().fg(Color::DarkGray)),
91                    Span::styled(
92                        format!("{}", issue.priority),
93                        Style::default().fg(priority_color),
94                    ),
95                ]),
96                Line::from(""),
97                if let Some(reporter) = &issue.reporter {
98                    Line::from(vec![
99                        Span::styled("Reporter: ", Style::default().fg(Color::DarkGray)),
100                        Span::raw(&reporter.display_name),
101                    ])
102                } else {
103                    Line::from("")
104                },
105                if let Some(assignee) = &issue.assignee {
106                    Line::from(vec![
107                        Span::styled("Assignee: ", Style::default().fg(Color::DarkGray)),
108                        Span::raw(&assignee.display_name),
109                    ])
110                } else {
111                    Line::from(vec![
112                        Span::styled("Assignee: ", Style::default().fg(Color::DarkGray)),
113                        Span::styled("Unassigned", Style::default().fg(Color::DarkGray)),
114                    ])
115                },
116                Line::from(""),
117                Line::from(vec![
118                    Span::styled("Created: ", Style::default().fg(Color::DarkGray)),
119                    Span::raw(issue.created_on.format("%Y-%m-%d %H:%M").to_string()),
120                ]),
121                Line::from(""),
122                if issue
123                    .content
124                    .as_ref()
125                    .and_then(|c| c.raw.as_ref())
126                    .is_some()
127                {
128                    Line::from(vec![Span::styled(
129                        "Description: ",
130                        Style::default().fg(Color::DarkGray),
131                    )])
132                } else {
133                    Line::from("")
134                },
135            ]
136        } else {
137            vec![Line::from(Span::styled(
138                "Select an issue to view details",
139                Style::default().fg(Color::DarkGray),
140            ))]
141        };
142
143        let details = Paragraph::new(content)
144            .block(Block::default().borders(Borders::ALL).title(" Details "));
145        f.render_widget(details, area);
146    }
147
148    fn issue_to_list_item(issue: &Issue) -> ListItem<'static> {
149        let kind_icon = match issue.kind {
150            IssueKind::Bug => "🐛",
151            IssueKind::Enhancement => "✨",
152            IssueKind::Proposal => "💡",
153            IssueKind::Task => "📋",
154        };
155
156        let state_color = Self::state_color(&issue.state);
157
158        ListItem::new(Line::from(vec![
159            Span::raw(format!("{} ", kind_icon)),
160            Span::styled(format!("#{} ", issue.id), Style::default().fg(state_color)),
161            Span::raw(issue.title.chars().take(45).collect::<String>()),
162        ]))
163    }
164
165    fn state_color(state: &IssueState) -> Color {
166        match state {
167            IssueState::New => Color::Cyan,
168            IssueState::Open => Color::Green,
169            IssueState::Resolved => Color::Blue,
170            IssueState::OnHold => Color::Yellow,
171            IssueState::Invalid | IssueState::Duplicate | IssueState::Wontfix => Color::DarkGray,
172            IssueState::Closed => Color::Magenta,
173        }
174    }
175
176    fn priority_color(priority: &IssuePriority) -> Color {
177        match priority {
178            IssuePriority::Trivial => Color::DarkGray,
179            IssuePriority::Minor => Color::White,
180            IssuePriority::Major => Color::Yellow,
181            IssuePriority::Critical => Color::Red,
182            IssuePriority::Blocker => Color::LightRed,
183        }
184    }
185}