bitbucket_cli/tui/
app.rs

1use anyhow::Result;
2use crossterm::{
3    event::{DisableMouseCapture, EnableMouseCapture},
4    execute,
5    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
6};
7use ratatui::{backend::CrosstermBackend, Terminal};
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.list_pull_requests(workspace, repo_slug, None, None, Some(10)).await {
233                        self.pull_requests.extend(prs.values);
234                    }
235                }
236            }
237            
238            self.clear_error();
239            self.loading = false;
240        } else {
241            self.set_error("No workspace configured");
242        }
243        Ok(())
244    }
245
246    /// Load issues for the current workspace
247    pub async fn load_issues(&mut self) -> Result<()> {
248        if let (Some(client), Some(workspace)) = (&self.client, &self.workspace) {
249            self.loading = true;
250            self.issues.clear();
251            
252            // Load issues from all repositories
253            if let Ok(repos) = client.list_repositories(workspace, None, Some(50)).await {
254                for repo in repos.values {
255                    let repo_slug = repo.slug.as_deref().unwrap_or(&repo.name);
256                    if let Ok(issues) = client.list_issues(workspace, repo_slug, None, None, Some(10)).await {
257                        self.issues.extend(issues.values);
258                    }
259                }
260            }
261            
262            self.clear_error();
263            self.loading = false;
264        } else {
265            self.set_error("No workspace configured");
266        }
267        Ok(())
268    }
269
270    /// Load pipelines for the current workspace
271    pub async fn load_pipelines(&mut self) -> Result<()> {
272        if let (Some(client), Some(workspace)) = (&self.client, &self.workspace) {
273            self.loading = true;
274            self.pipelines.clear();
275            
276            // Load pipelines from all repositories
277            if let Ok(repos) = client.list_repositories(workspace, None, Some(50)).await {
278                for repo in repos.values {
279                    let repo_slug = repo.slug.as_deref().unwrap_or(&repo.name);
280                    if let Ok(pipelines) = client.list_pipelines(workspace, repo_slug, None, Some(10)).await {
281                        self.pipelines.extend(pipelines.values);
282                    }
283                }
284            }
285            
286            self.clear_error();
287            self.loading = false;
288        } else {
289            self.set_error("No workspace configured");
290        }
291        Ok(())
292    }
293
294    /// Load all data
295    pub async fn load_all_data(&mut self) -> Result<()> {
296        self.load_repositories().await?;
297        self.load_pull_requests().await?;
298        self.load_issues().await?;
299        self.load_pipelines().await?;
300        Ok(())
301    }
302}
303
304impl Default for App {
305    fn default() -> Self {
306        Self::new()
307    }
308}
309
310/// Run the TUI application
311pub async fn run_tui(workspace: Option<String>) -> Result<()> {
312    // Setup terminal
313    enable_raw_mode()?;
314    let mut stdout = io::stdout();
315    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
316    let backend = CrosstermBackend::new(stdout);
317    let mut terminal = Terminal::new(backend)?;
318
319    // Create app
320    let mut app = App::new();
321
322    // Try to get API client
323    match BitbucketClient::from_stored() {
324        Ok(client) => {
325            app = app.with_client(client);
326            if let Some(ws) = workspace {
327                app = app.with_workspace(ws);
328            } else {
329                app.set_error("No workspace specified. Use: bitbucket tui --workspace <workspace>");
330            }
331        }
332        Err(e) => {
333            app.set_error(&format!("Not authenticated: {}", e));
334        }
335    }
336
337    // Load initial data if we have a workspace
338    if app.workspace.is_some() && app.client.is_some() {
339        app.set_status("Loading data...");
340        terminal.draw(|f| ui::draw(f, &app))?;
341        
342        if let Err(e) = app.load_repositories().await {
343            app.set_error(&format!("Failed to load data: {}", e));
344        } else {
345            app.set_status("Data loaded. Press 'r' to refresh.");
346        }
347    }
348
349    // Create event handler
350    let event_handler = EventHandler::new(250);
351    let mut should_refresh = false;
352
353    // Main loop
354    while app.running {
355        // Draw UI
356        terminal.draw(|f| ui::draw(f, &app))?;
357
358        // Handle refresh if requested
359        if should_refresh && app.workspace.is_some() && app.client.is_some() {
360            should_refresh = false;
361            app.set_status("Refreshing...");
362            terminal.draw(|f| ui::draw(f, &app))?;
363            
364            match app.current_view {
365                View::Dashboard | View::Repositories => {
366                    let _ = app.load_repositories().await;
367                }
368                View::PullRequests => {
369                    let _ = app.load_pull_requests().await;
370                }
371                View::Issues => {
372                    let _ = app.load_issues().await;
373                }
374                View::Pipelines => {
375                    let _ = app.load_pipelines().await;
376                }
377            }
378            
379            app.set_status("Refreshed");
380        }
381
382        // Handle events
383        match event_handler.next()? {
384            Event::Key(key) => {
385                // Check if refresh was requested
386                if let crossterm::event::KeyCode::Char('r') = key.code {
387                    should_refresh = true;
388                }
389                app.handle_key(key);
390            }
391            Event::Tick => {
392                // Periodic tick for animations, etc.
393            }
394            Event::Resize(_, _) => {
395                // Terminal will redraw automatically
396            }
397            _ => {}
398        }
399    }
400
401    // Restore terminal
402    disable_raw_mode()?;
403    execute!(
404        terminal.backend_mut(),
405        LeaveAlternateScreen,
406        DisableMouseCapture
407    )?;
408    terminal.show_cursor()?;
409
410    Ok(())
411}