bitbucket_cli/tui/
app.rs

1use anyhow::Result;
2use crossterm::{
3    event::{DisableMouseCapture, EnableMouseCapture},
4    execute,
5    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
6};
7use ratatui::{Terminal, backend::CrosstermBackend};
8use std::io;
9
10use super::event::{Event, EventHandler};
11use super::ui;
12use super::views::{View, ViewState};
13use crate::api::BitbucketClient;
14use crate::models::{Issue, Pipeline, PullRequest, Repository};
15
16/// Application state
17pub struct App {
18    /// Is the application running
19    pub running: bool,
20    /// Current view
21    pub current_view: View,
22    /// View-specific state
23    pub view_state: ViewState,
24    /// API client
25    pub client: Option<BitbucketClient>,
26    /// Current workspace
27    pub workspace: Option<String>,
28    /// Status message
29    pub status: Option<String>,
30    /// Is loading data
31    pub loading: bool,
32    /// Error message
33    pub error: Option<String>,
34
35    // Data
36    pub repositories: Vec<Repository>,
37    pub pull_requests: Vec<PullRequest>,
38    pub issues: Vec<Issue>,
39    pub pipelines: Vec<Pipeline>,
40}
41
42impl App {
43    pub fn new() -> Self {
44        Self {
45            running: true,
46            current_view: View::Dashboard,
47            view_state: ViewState::default(),
48            client: None,
49            workspace: None,
50            status: None,
51            loading: false,
52            error: None,
53            repositories: Vec::new(),
54            pull_requests: Vec::new(),
55            issues: Vec::new(),
56            pipelines: Vec::new(),
57        }
58    }
59
60    /// Initialize the application with API client
61    pub fn with_client(mut self, client: BitbucketClient) -> Self {
62        self.client = Some(client);
63        self
64    }
65
66    /// Set the workspace
67    pub fn with_workspace(mut self, workspace: String) -> Self {
68        self.workspace = Some(workspace);
69        self
70    }
71
72    /// Set status message
73    pub fn set_status(&mut self, message: &str) {
74        self.status = Some(message.to_string());
75    }
76
77    /// Clear status message
78    pub fn clear_status(&mut self) {
79        self.status = None;
80    }
81
82    /// Set error message
83    pub fn set_error(&mut self, message: &str) {
84        self.error = Some(message.to_string());
85    }
86
87    /// Clear error
88    pub fn clear_error(&mut self) {
89        self.error = None;
90    }
91
92    /// Switch to a different view
93    pub fn switch_view(&mut self, view: View) {
94        self.current_view = view;
95        self.view_state.selected_index = 0;
96        self.clear_error();
97    }
98
99    /// Handle keyboard input
100    pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) {
101        use crossterm::event::KeyCode;
102
103        // Global keys
104        match key.code {
105            KeyCode::Char('q') => {
106                self.running = false;
107                return;
108            }
109            KeyCode::Char('1') => {
110                self.switch_view(View::Dashboard);
111                return;
112            }
113            KeyCode::Char('2') => {
114                self.switch_view(View::Repositories);
115                return;
116            }
117            KeyCode::Char('3') => {
118                self.switch_view(View::PullRequests);
119                return;
120            }
121            KeyCode::Char('4') => {
122                self.switch_view(View::Issues);
123                return;
124            }
125            KeyCode::Char('5') => {
126                self.switch_view(View::Pipelines);
127                return;
128            }
129            KeyCode::Esc => {
130                self.clear_error();
131                return;
132            }
133            _ => {}
134        }
135
136        // View-specific keys
137        match key.code {
138            KeyCode::Up | KeyCode::Char('k') => {
139                self.view_state.previous();
140            }
141            KeyCode::Down | KeyCode::Char('j') => {
142                let max = match self.current_view {
143                    View::Dashboard => 4,
144                    View::Repositories => self.repositories.len(),
145                    View::PullRequests => self.pull_requests.len(),
146                    View::Issues => self.issues.len(),
147                    View::Pipelines => self.pipelines.len(),
148                };
149                self.view_state.next(max);
150            }
151            KeyCode::Enter => {
152                self.handle_select();
153            }
154            KeyCode::Char('r') => {
155                // Refresh will be handled in main loop
156            }
157            _ => {}
158        }
159    }
160
161    /// Handle selection
162    fn handle_select(&mut self) {
163        match self.current_view {
164            View::Dashboard => {
165                // Navigate to selected section
166                match self.view_state.selected_index {
167                    0 => self.switch_view(View::Repositories),
168                    1 => self.switch_view(View::PullRequests),
169                    2 => self.switch_view(View::Issues),
170                    3 => self.switch_view(View::Pipelines),
171                    _ => {}
172                }
173            }
174            View::Repositories => {
175                if let Some(repo) = self.repositories.get(self.view_state.selected_index) {
176                    self.set_status(&format!("Selected: {}", repo.full_name));
177                }
178            }
179            View::PullRequests => {
180                if let Some(pr) = self.pull_requests.get(self.view_state.selected_index) {
181                    self.set_status(&format!("Selected PR #{}: {}", pr.id, pr.title));
182                }
183            }
184            View::Issues => {
185                if let Some(issue) = self.issues.get(self.view_state.selected_index) {
186                    self.set_status(&format!("Selected Issue #{}: {}", issue.id, issue.title));
187                }
188            }
189            View::Pipelines => {
190                if let Some(pipeline) = self.pipelines.get(self.view_state.selected_index) {
191                    self.set_status(&format!("Selected Pipeline #{}", pipeline.build_number));
192                }
193            }
194        }
195    }
196
197    /// Quit the application
198    pub fn quit(&mut self) {
199        self.running = false;
200    }
201
202    /// Load repositories
203    pub async fn load_repositories(&mut self) -> Result<()> {
204        if let (Some(client), Some(workspace)) = (&self.client, &self.workspace) {
205            self.loading = true;
206            match client.list_repositories(workspace, None, Some(50)).await {
207                Ok(result) => {
208                    self.repositories = result.values;
209                    self.clear_error();
210                }
211                Err(e) => {
212                    self.set_error(&format!("Failed to load repositories: {}", e));
213                }
214            }
215            self.loading = false;
216        } else {
217            self.set_error("No workspace configured");
218        }
219        Ok(())
220    }
221
222    /// Load pull requests for the current workspace
223    pub async fn load_pull_requests(&mut self) -> Result<()> {
224        if let (Some(client), Some(workspace)) = (&self.client, &self.workspace) {
225            self.loading = true;
226            self.pull_requests.clear();
227
228            // Load PRs from all repositories
229            if let Ok(repos) = client.list_repositories(workspace, None, Some(50)).await {
230                for repo in repos.values {
231                    let repo_slug = repo.slug.as_deref().unwrap_or(&repo.name);
232                    if let Ok(prs) = client
233                        .list_pull_requests(workspace, repo_slug, None, None, Some(10))
234                        .await
235                    {
236                        self.pull_requests.extend(prs.values);
237                    }
238                }
239            }
240
241            self.clear_error();
242            self.loading = false;
243        } else {
244            self.set_error("No workspace configured");
245        }
246        Ok(())
247    }
248
249    /// Load issues for the current workspace
250    pub async fn load_issues(&mut self) -> Result<()> {
251        if let (Some(client), Some(workspace)) = (&self.client, &self.workspace) {
252            self.loading = true;
253            self.issues.clear();
254
255            // Load issues from all repositories
256            if let Ok(repos) = client.list_repositories(workspace, None, Some(50)).await {
257                for repo in repos.values {
258                    let repo_slug = repo.slug.as_deref().unwrap_or(&repo.name);
259                    if let Ok(issues) = client
260                        .list_issues(workspace, repo_slug, None, None, Some(10))
261                        .await
262                    {
263                        self.issues.extend(issues.values);
264                    }
265                }
266            }
267
268            self.clear_error();
269            self.loading = false;
270        } else {
271            self.set_error("No workspace configured");
272        }
273        Ok(())
274    }
275
276    /// Load pipelines for the current workspace
277    pub async fn load_pipelines(&mut self) -> Result<()> {
278        if let (Some(client), Some(workspace)) = (&self.client, &self.workspace) {
279            self.loading = true;
280            self.pipelines.clear();
281
282            // Load pipelines from all repositories
283            if let Ok(repos) = client.list_repositories(workspace, None, Some(50)).await {
284                for repo in repos.values {
285                    let repo_slug = repo.slug.as_deref().unwrap_or(&repo.name);
286                    if let Ok(pipelines) = client
287                        .list_pipelines(workspace, repo_slug, None, Some(10))
288                        .await
289                    {
290                        self.pipelines.extend(pipelines.values);
291                    }
292                }
293            }
294
295            self.clear_error();
296            self.loading = false;
297        } else {
298            self.set_error("No workspace configured");
299        }
300        Ok(())
301    }
302
303    /// Load all data
304    pub async fn load_all_data(&mut self) -> Result<()> {
305        self.load_repositories().await?;
306        self.load_pull_requests().await?;
307        self.load_issues().await?;
308        self.load_pipelines().await?;
309        Ok(())
310    }
311}
312
313impl Default for App {
314    fn default() -> Self {
315        Self::new()
316    }
317}
318
319/// Run the TUI application
320pub async fn run_tui(workspace: Option<String>) -> Result<()> {
321    // Setup terminal
322    enable_raw_mode()?;
323    let mut stdout = io::stdout();
324    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
325    let backend = CrosstermBackend::new(stdout);
326    let mut terminal = Terminal::new(backend)?;
327
328    // Create app
329    let mut app = App::new();
330
331    // Try to get API client
332    match BitbucketClient::from_stored() {
333        Ok(client) => {
334            app = app.with_client(client);
335            if let Some(ws) = workspace {
336                app = app.with_workspace(ws);
337            } else {
338                app.set_error("No workspace specified. Use: bitbucket tui --workspace <workspace>");
339            }
340        }
341        Err(e) => {
342            app.set_error(&format!("Not authenticated: {}", e));
343        }
344    }
345
346    // Load initial data if we have a workspace
347    if app.workspace.is_some() && app.client.is_some() {
348        app.set_status("Loading data...");
349        terminal.draw(|f| ui::draw(f, &app))?;
350
351        if let Err(e) = app.load_repositories().await {
352            app.set_error(&format!("Failed to load data: {}", e));
353        } else {
354            app.set_status("Data loaded. Press 'r' to refresh.");
355        }
356    }
357
358    // Create event handler
359    let event_handler = EventHandler::new(250);
360    let mut should_refresh = false;
361
362    // Main loop
363    while app.running {
364        // Draw UI
365        terminal.draw(|f| ui::draw(f, &app))?;
366
367        // Handle refresh if requested
368        if should_refresh && app.workspace.is_some() && app.client.is_some() {
369            should_refresh = false;
370            app.set_status("Refreshing...");
371            terminal.draw(|f| ui::draw(f, &app))?;
372
373            match app.current_view {
374                View::Dashboard | View::Repositories => {
375                    let _ = app.load_repositories().await;
376                }
377                View::PullRequests => {
378                    let _ = app.load_pull_requests().await;
379                }
380                View::Issues => {
381                    let _ = app.load_issues().await;
382                }
383                View::Pipelines => {
384                    let _ = app.load_pipelines().await;
385                }
386            }
387
388            app.set_status("Refreshed");
389        }
390
391        // Handle events
392        match event_handler.next()? {
393            Event::Key(key) => {
394                // Check if refresh was requested
395                if let crossterm::event::KeyCode::Char('r') = key.code {
396                    should_refresh = true;
397                }
398                app.handle_key(key);
399            }
400            Event::Tick => {
401                // Periodic tick for animations, etc.
402            }
403            Event::Resize(_, _) => {
404                // Terminal will redraw automatically
405            }
406            _ => {}
407        }
408    }
409
410    // Restore terminal
411    disable_raw_mode()?;
412    execute!(
413        terminal.backend_mut(),
414        LeaveAlternateScreen,
415        DisableMouseCapture
416    )?;
417    terminal.show_cursor()?;
418
419    Ok(())
420}