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
16pub struct App {
18 pub running: bool,
20 pub current_view: View,
22 pub view_state: ViewState,
24 pub client: Option<BitbucketClient>,
26 pub workspace: Option<String>,
28 pub status: Option<String>,
30 pub loading: bool,
32 pub error: Option<String>,
34
35 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 pub fn with_client(mut self, client: BitbucketClient) -> Self {
62 self.client = Some(client);
63 self
64 }
65
66 pub fn with_workspace(mut self, workspace: String) -> Self {
68 self.workspace = Some(workspace);
69 self
70 }
71
72 pub fn set_status(&mut self, message: &str) {
74 self.status = Some(message.to_string());
75 }
76
77 pub fn clear_status(&mut self) {
79 self.status = None;
80 }
81
82 pub fn set_error(&mut self, message: &str) {
84 self.error = Some(message.to_string());
85 }
86
87 pub fn clear_error(&mut self) {
89 self.error = None;
90 }
91
92 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 pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) {
101 use crossterm::event::KeyCode;
102
103 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 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 }
157 _ => {}
158 }
159 }
160
161 fn handle_select(&mut self) {
163 match self.current_view {
164 View::Dashboard => {
165 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 pub fn quit(&mut self) {
199 self.running = false;
200 }
201
202 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 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 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 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 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 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 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 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
310pub async fn run_tui(workspace: Option<String>) -> Result<()> {
312 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 let mut app = App::new();
321
322 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 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 let event_handler = EventHandler::new(250);
351 let mut should_refresh = false;
352
353 while app.running {
355 terminal.draw(|f| ui::draw(f, &app))?;
357
358 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 match event_handler.next()? {
384 Event::Key(key) => {
385 if let crossterm::event::KeyCode::Char('r') = key.code {
387 should_refresh = true;
388 }
389 app.handle_key(key);
390 }
391 Event::Tick => {
392 }
394 Event::Resize(_, _) => {
395 }
397 _ => {}
398 }
399 }
400
401 disable_raw_mode()?;
403 execute!(
404 terminal.backend_mut(),
405 LeaveAlternateScreen,
406 DisableMouseCapture
407 )?;
408 terminal.show_cursor()?;
409
410 Ok(())
411}