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 self.set_status("Refreshing...");
156 }
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
203impl Default for App {
204 fn default() -> Self {
205 Self::new()
206 }
207}
208
209pub async fn run_tui(workspace: Option<String>) -> Result<()> {
211 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 let mut app = App::new();
220
221 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 let event_handler = EventHandler::new(250);
236
237 while app.running {
239 terminal.draw(|f| ui::draw(f, &app))?;
241
242 match event_handler.next()? {
244 Event::Key(key) => {
245 app.handle_key(key);
246 }
247 Event::Tick => {
248 }
250 Event::Resize(_, _) => {
251 }
253 _ => {}
254 }
255 }
256
257 disable_raw_mode()?;
259 execute!(
260 terminal.backend_mut(),
261 LeaveAlternateScreen,
262 DisableMouseCapture
263 )?;
264 terminal.show_cursor()?;
265
266 Ok(())
267}