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                self.set_status("Refreshing...");
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
203impl Default for App {
204    fn default() -> Self {
205        Self::new()
206    }
207}
208
209/// Run the TUI application
210pub async fn run_tui(workspace: Option<String>) -> Result<()> {
211    // Setup terminal
212    enable_raw_mode()?;
213    let mut stdout = io::stdout();
214    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
215    let backend = CrosstermBackend::new(stdout);
216    let mut terminal = Terminal::new(backend)?;
217
218    // Create app
219    let mut app = App::new();
220
221    // Try to get API client
222    match BitbucketClient::from_stored() {
223        Ok(client) => {
224            app = app.with_client(client);
225            if let Some(ws) = workspace {
226                app = app.with_workspace(ws);
227            }
228        }
229        Err(e) => {
230            app.set_error(&format!("Not authenticated: {}", e));
231        }
232    }
233
234    // Create event handler
235    let event_handler = EventHandler::new(250);
236
237    // Main loop
238    while app.running {
239        // Draw UI
240        terminal.draw(|f| ui::draw(f, &app))?;
241
242        // Handle events
243        match event_handler.next()? {
244            Event::Key(key) => {
245                app.handle_key(key);
246            }
247            Event::Tick => {
248                // Could refresh data here
249            }
250            Event::Resize(_, _) => {
251                // Terminal will redraw automatically
252            }
253            _ => {}
254        }
255    }
256
257    // Restore terminal
258    disable_raw_mode()?;
259    execute!(
260        terminal.backend_mut(),
261        LeaveAlternateScreen,
262        DisableMouseCapture
263    )?;
264    terminal.show_cursor()?;
265
266    Ok(())
267}