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
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
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 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 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 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 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 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
319pub async fn run_tui(workspace: Option<String>) -> Result<()> {
321 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 let mut app = App::new();
330
331 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 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 let event_handler = EventHandler::new(250);
360 let mut should_refresh = false;
361
362 while app.running {
364 terminal.draw(|f| ui::draw(f, &app))?;
366
367 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 match event_handler.next()? {
393 Event::Key(key) => {
394 if let crossterm::event::KeyCode::Char('r') = key.code {
396 should_refresh = true;
397 }
398 app.handle_key(key);
399 }
400 Event::Tick => {
401 }
403 Event::Resize(_, _) => {
404 }
406 _ => {}
407 }
408 }
409
410 disable_raw_mode()?;
412 execute!(
413 terminal.backend_mut(),
414 LeaveAlternateScreen,
415 DisableMouseCapture
416 )?;
417 terminal.show_cursor()?;
418
419 Ok(())
420}