bitbucket_cli/tui/
ui.rs

1use ratatui::{
2    Frame,
3    layout::{Constraint, Direction, Layout, Rect},
4    style::{Color, Modifier, Style},
5    text::{Line, Span},
6    widgets::{Block, Borders, List, ListItem, Paragraph, Tabs},
7};
8
9use super::app::App;
10use super::views::View;
11
12/// Draw the application
13pub fn draw(f: &mut Frame, app: &App) {
14    let chunks = Layout::default()
15        .direction(Direction::Vertical)
16        .constraints([
17            Constraint::Length(3), // Header
18            Constraint::Min(0),    // Main content
19            Constraint::Length(3), // Footer
20        ])
21        .split(f.area());
22
23    draw_header(f, app, chunks[0]);
24    draw_main(f, app, chunks[1]);
25    draw_footer(f, app, chunks[2]);
26}
27
28fn draw_header(f: &mut Frame, app: &App, area: Rect) {
29    let titles = vec!["Dashboard", "Repos", "PRs", "Issues", "Pipelines"];
30    let selected = match app.current_view {
31        View::Dashboard => 0,
32        View::Repositories => 1,
33        View::PullRequests => 2,
34        View::Issues => 3,
35        View::Pipelines => 4,
36    };
37
38    let tabs = Tabs::new(titles)
39        .block(
40            Block::default()
41                .borders(Borders::ALL)
42                .title(" Bitbucket CLI "),
43        )
44        .select(selected)
45        .style(Style::default().fg(Color::White))
46        .highlight_style(
47            Style::default()
48                .fg(Color::Cyan)
49                .add_modifier(Modifier::BOLD),
50        );
51
52    f.render_widget(tabs, area);
53}
54
55fn draw_main(f: &mut Frame, app: &App, area: Rect) {
56    match app.current_view {
57        View::Dashboard => draw_dashboard(f, app, area),
58        View::Repositories => draw_repositories(f, app, area),
59        View::PullRequests => draw_pull_requests(f, app, area),
60        View::Issues => draw_issues(f, app, area),
61        View::Pipelines => draw_pipelines(f, app, area),
62    }
63}
64
65fn draw_dashboard(f: &mut Frame, app: &App, area: Rect) {
66    let chunks = Layout::default()
67        .direction(Direction::Vertical)
68        .constraints([Constraint::Length(3), Constraint::Min(0)])
69        .split(area);
70
71    // Workspace info
72    let workspace_text = match &app.workspace {
73        Some(ws) => format!("Workspace: {}", ws),
74        None => "No workspace selected".to_string(),
75    };
76    let workspace = Paragraph::new(workspace_text)
77        .block(Block::default().borders(Borders::ALL).title(" Workspace "));
78    f.render_widget(workspace, chunks[0]);
79
80    // Dashboard menu
81    let items: Vec<ListItem> = vec![
82        ListItem::new(Line::from(vec![
83            Span::styled("πŸ“ ", Style::default()),
84            Span::raw("Repositories"),
85            Span::styled(
86                format!(" ({})", app.repositories.len()),
87                Style::default().fg(Color::DarkGray),
88            ),
89        ])),
90        ListItem::new(Line::from(vec![
91            Span::styled("πŸ”€ ", Style::default()),
92            Span::raw("Pull Requests"),
93            Span::styled(
94                format!(" ({})", app.pull_requests.len()),
95                Style::default().fg(Color::DarkGray),
96            ),
97        ])),
98        ListItem::new(Line::from(vec![
99            Span::styled("πŸ› ", Style::default()),
100            Span::raw("Issues"),
101            Span::styled(
102                format!(" ({})", app.issues.len()),
103                Style::default().fg(Color::DarkGray),
104            ),
105        ])),
106        ListItem::new(Line::from(vec![
107            Span::styled("βš™οΈ  ", Style::default()),
108            Span::raw("Pipelines"),
109            Span::styled(
110                format!(" ({})", app.pipelines.len()),
111                Style::default().fg(Color::DarkGray),
112            ),
113        ])),
114    ];
115
116    let list = List::new(items)
117        .block(
118            Block::default()
119                .borders(Borders::ALL)
120                .title(" Quick Access "),
121        )
122        .highlight_style(
123            Style::default()
124                .bg(Color::DarkGray)
125                .add_modifier(Modifier::BOLD),
126        )
127        .highlight_symbol("β–Ά ");
128
129    let mut state = ratatui::widgets::ListState::default();
130    state.select(Some(app.view_state.selected_index));
131    f.render_stateful_widget(list, chunks[1], &mut state);
132}
133
134fn draw_repositories(f: &mut Frame, app: &App, area: Rect) {
135    let items: Vec<ListItem> = if app.repositories.is_empty() {
136        vec![ListItem::new(
137            "No repositories loaded. Press 'r' to refresh.",
138        )]
139    } else {
140        app.repositories
141            .iter()
142            .map(|repo| {
143                let private_badge = if repo.is_private.unwrap_or(false) {
144                    "πŸ”’"
145                } else {
146                    "🌐"
147                };
148                ListItem::new(Line::from(vec![
149                    Span::raw(format!("{} ", private_badge)),
150                    Span::styled(&repo.full_name, Style::default().fg(Color::Cyan)),
151                    Span::raw(" - "),
152                    Span::styled(
153                        repo.description.as_deref().unwrap_or("No description"),
154                        Style::default().fg(Color::DarkGray),
155                    ),
156                ]))
157            })
158            .collect()
159    };
160
161    let list = List::new(items)
162        .block(
163            Block::default()
164                .borders(Borders::ALL)
165                .title(" Repositories "),
166        )
167        .highlight_style(
168            Style::default()
169                .bg(Color::DarkGray)
170                .add_modifier(Modifier::BOLD),
171        )
172        .highlight_symbol("β–Ά ");
173
174    let mut state = ratatui::widgets::ListState::default();
175    state.select(Some(app.view_state.selected_index));
176    f.render_stateful_widget(list, area, &mut state);
177}
178
179fn draw_pull_requests(f: &mut Frame, app: &App, area: Rect) {
180    let items: Vec<ListItem> = if app.pull_requests.is_empty() {
181        vec![ListItem::new(
182            "No pull requests loaded. Press 'r' to refresh.",
183        )]
184    } else {
185        app.pull_requests
186            .iter()
187            .map(|pr| {
188                let state_color = match pr.state {
189                    crate::models::PullRequestState::Open => Color::Green,
190                    crate::models::PullRequestState::Merged => Color::Magenta,
191                    crate::models::PullRequestState::Declined => Color::Red,
192                    crate::models::PullRequestState::Superseded => Color::Yellow,
193                };
194                ListItem::new(Line::from(vec![
195                    Span::styled(format!("[{}] ", pr.state), Style::default().fg(state_color)),
196                    Span::styled(format!("#{} ", pr.id), Style::default().fg(Color::DarkGray)),
197                    Span::raw(&pr.title),
198                ]))
199            })
200            .collect()
201    };
202
203    let list = List::new(items)
204        .block(
205            Block::default()
206                .borders(Borders::ALL)
207                .title(" Pull Requests "),
208        )
209        .highlight_style(
210            Style::default()
211                .bg(Color::DarkGray)
212                .add_modifier(Modifier::BOLD),
213        )
214        .highlight_symbol("β–Ά ");
215
216    let mut state = ratatui::widgets::ListState::default();
217    state.select(Some(app.view_state.selected_index));
218    f.render_stateful_widget(list, area, &mut state);
219}
220
221fn draw_issues(f: &mut Frame, app: &App, area: Rect) {
222    let items: Vec<ListItem> = if app.issues.is_empty() {
223        vec![ListItem::new("No issues loaded. Press 'r' to refresh.")]
224    } else {
225        app.issues
226            .iter()
227            .map(|issue| {
228                let kind_icon = match issue.kind {
229                    crate::models::IssueKind::Bug => "πŸ›",
230                    crate::models::IssueKind::Enhancement => "✨",
231                    crate::models::IssueKind::Proposal => "πŸ’‘",
232                    crate::models::IssueKind::Task => "πŸ“‹",
233                };
234                ListItem::new(Line::from(vec![
235                    Span::raw(format!("{} ", kind_icon)),
236                    Span::styled(
237                        format!("#{} ", issue.id),
238                        Style::default().fg(Color::DarkGray),
239                    ),
240                    Span::raw(&issue.title),
241                ]))
242            })
243            .collect()
244    };
245
246    let list = List::new(items)
247        .block(Block::default().borders(Borders::ALL).title(" Issues "))
248        .highlight_style(
249            Style::default()
250                .bg(Color::DarkGray)
251                .add_modifier(Modifier::BOLD),
252        )
253        .highlight_symbol("β–Ά ");
254
255    let mut state = ratatui::widgets::ListState::default();
256    state.select(Some(app.view_state.selected_index));
257    f.render_stateful_widget(list, area, &mut state);
258}
259
260fn draw_pipelines(f: &mut Frame, app: &App, area: Rect) {
261    let items: Vec<ListItem> = if app.pipelines.is_empty() {
262        vec![ListItem::new("No pipelines loaded. Press 'r' to refresh.")]
263    } else {
264        app.pipelines
265            .iter()
266            .map(|pipeline| {
267                let (status_icon, status_color) = match pipeline.state.name {
268                    crate::models::PipelineStateName::Pending => ("⏳", Color::Yellow),
269                    crate::models::PipelineStateName::Building => ("πŸ”„", Color::Blue),
270                    crate::models::PipelineStateName::Completed => {
271                        if let Some(result) = &pipeline.state.result {
272                            match result.name {
273                                crate::models::PipelineResultName::Successful => {
274                                    ("βœ…", Color::Green)
275                                }
276                                crate::models::PipelineResultName::Failed => ("❌", Color::Red),
277                                _ => ("βšͺ", Color::Gray),
278                            }
279                        } else {
280                            ("βšͺ", Color::Gray)
281                        }
282                    }
283                    crate::models::PipelineStateName::Halted => ("β›”", Color::Red),
284                    crate::models::PipelineStateName::Paused => ("⏸️", Color::Yellow),
285                };
286                ListItem::new(Line::from(vec![
287                    Span::raw(format!("{} ", status_icon)),
288                    Span::styled(
289                        format!("#{} ", pipeline.build_number),
290                        Style::default().fg(status_color),
291                    ),
292                    Span::raw(pipeline.target.ref_name.as_deref().unwrap_or("unknown")),
293                ]))
294            })
295            .collect()
296    };
297
298    let list = List::new(items)
299        .block(Block::default().borders(Borders::ALL).title(" Pipelines "))
300        .highlight_style(
301            Style::default()
302                .bg(Color::DarkGray)
303                .add_modifier(Modifier::BOLD),
304        )
305        .highlight_symbol("β–Ά ");
306
307    let mut state = ratatui::widgets::ListState::default();
308    state.select(Some(app.view_state.selected_index));
309    f.render_stateful_widget(list, area, &mut state);
310}
311
312fn draw_footer(f: &mut Frame, app: &App, area: Rect) {
313    let status_text = if let Some(error) = &app.error {
314        Line::from(Span::styled(
315            format!("Error: {}", error),
316            Style::default().fg(Color::Red),
317        ))
318    } else if let Some(status) = &app.status {
319        Line::from(Span::styled(status, Style::default().fg(Color::Yellow)))
320    } else if app.loading {
321        Line::from(Span::styled(
322            "Loading...",
323            Style::default().fg(Color::Yellow),
324        ))
325    } else {
326        Line::from(vec![
327            Span::styled("q", Style::default().fg(Color::Cyan)),
328            Span::raw(" quit  "),
329            Span::styled("1-5", Style::default().fg(Color::Cyan)),
330            Span::raw(" switch view  "),
331            Span::styled("j/k", Style::default().fg(Color::Cyan)),
332            Span::raw(" navigate  "),
333            Span::styled("Enter", Style::default().fg(Color::Cyan)),
334            Span::raw(" select  "),
335            Span::styled("r", Style::default().fg(Color::Cyan)),
336            Span::raw(" refresh"),
337        ])
338    };
339
340    let footer =
341        Paragraph::new(status_text).block(Block::default().borders(Borders::ALL).title(" Help "));
342    f.render_widget(footer, area);
343}