gitai/tui/
app.rs

1use super::input_handler::{InputResult, handle_input};
2use super::spinner::SpinnerState;
3use super::state::{Mode, TuiState};
4use super::ui::draw_ui;
5use crate::debug;
6use crate::features::commit::{CommitService, format_commit_result, types::GeneratedMessage};
7use anyhow::{Error, Result};
8use crossterm::event::KeyEventKind;
9use ratatui::{
10    Terminal,
11    backend::CrosstermBackend,
12    crossterm::{
13        event::{self, Event},
14        execute,
15        terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
16    },
17};
18
19use std::io;
20use std::sync::Arc;
21use std::time::Duration;
22
23pub struct TuiCommit {
24    pub state: TuiState,
25    service: Arc<CommitService>,
26}
27
28impl TuiCommit {
29    pub fn new(
30        initial_messages: Vec<GeneratedMessage>,
31        custom_instructions: String,
32        preset: String,
33        user_name: String,
34        user_email: String,
35        service: Arc<CommitService>,
36    ) -> Self {
37        let state = TuiState::new(
38            initial_messages,
39            custom_instructions,
40            preset,
41            user_name,
42            user_email,
43        );
44
45        Self { state, service }
46    }
47
48    #[allow(clippy::unused_async)]
49    pub async fn run(
50        initial_messages: Vec<GeneratedMessage>,
51        custom_instructions: String,
52        selected_preset: String,
53        user_name: String,
54        user_email: String,
55        service: Arc<CommitService>,
56    ) -> Result<()> {
57        let mut app = Self::new(
58            initial_messages,
59            custom_instructions,
60            selected_preset,
61            user_name,
62            user_email,
63            service,
64        );
65
66        app.run_app().map_err(Error::from)
67    }
68
69    pub fn run_app(&mut self) -> io::Result<()> {
70        // Setup
71        enable_raw_mode()?;
72        let mut stdout = io::stdout();
73        execute!(stdout, EnterAlternateScreen)?;
74        let backend = CrosstermBackend::new(stdout);
75        let mut terminal = Terminal::new(backend)?;
76
77        // Run main loop
78        let result = self.main_loop(&mut terminal);
79
80        // Cleanup
81        disable_raw_mode()?;
82        execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
83        terminal.show_cursor()?;
84
85        // Handle result and display appropriate message
86        match result {
87            Ok(exit_status) => match exit_status {
88                ExitStatus::Committed(message) => {
89                    println!("{message}");
90                }
91                ExitStatus::Cancelled => {
92                    println!("Commit operation cancelled. Your changes remain staged.");
93                }
94                ExitStatus::Error(error_message) => {
95                    eprintln!("An error occurred: {error_message}");
96                }
97            },
98            Err(e) => {
99                eprintln!("An unexpected error occurred: {e}");
100                return Err(io::Error::other(e.to_string()));
101            }
102        }
103
104        Ok(())
105    }
106
107    fn main_loop(
108        &mut self,
109        terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
110    ) -> anyhow::Result<ExitStatus> {
111        let (tx, mut rx) = tokio::sync::mpsc::channel::<Result<GeneratedMessage, anyhow::Error>>(1);
112        let mut task_spawned = false;
113
114        loop {
115            // Redraw only if dirty
116            if self.state.dirty {
117                terminal.draw(|f| draw_ui(f, &mut self.state))?;
118                self.state.dirty = false; // Reset dirty flag after redraw
119            }
120
121            // Spawn the task only once when entering the Generating mode
122            if self.state.mode == Mode::Generating && !task_spawned {
123                let service = self.service.clone();
124                let preset = self.state.selected_preset.clone();
125                let instructions = self.state.custom_instructions.clone();
126                let tx = tx.clone();
127
128                tokio::spawn(async move {
129                    debug!("Generating message...");
130                    let result = service.generate_message(&preset, &instructions).await;
131                    let _ = tx.send(result).await;
132                });
133
134                task_spawned = true; // Ensure we only spawn the task once
135            }
136
137            // Check if a message has been received from the generation task
138            match rx.try_recv() {
139                Ok(result) => match result {
140                    Ok(new_message) => {
141                        self.state.messages.push(new_message);
142                        self.state.current_index = self.state.messages.len() - 1;
143
144                        self.state.update_message_textarea();
145                        self.state.mode = Mode::Normal; // Exit Generating mode
146                        self.state.spinner = None; // Stop the spinner
147                        self.state
148                            .set_status(String::from("New message generated successfully!"));
149                        task_spawned = false; // Reset for future regenerations
150                    }
151                    Err(e) => {
152                        self.state.mode = Mode::Normal; // Exit Generating mode
153                        self.state.spinner = None; // Stop the spinner
154                        self.state
155                            .set_status(format!("Failed to generate new message: {e}. Press 'r' to retry or 'Esc' to exit."));
156                        task_spawned = false; // Reset for future regenerations
157                    }
158                },
159                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
160                    // No message available yet, continue the loop
161                }
162                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
163                    // Handle the case where the sender has disconnected
164                    break;
165                }
166            }
167
168            // Poll for input events
169            if event::poll(Duration::from_millis(20))?
170                && let Event::Key(key) = event::read()?
171                && key.kind == KeyEventKind::Press
172            {
173                match handle_input(self, key) {
174                    InputResult::Exit => return Ok(ExitStatus::Cancelled),
175                    InputResult::Commit(message) => match self.perform_commit(&message) {
176                        Ok(status) => return Ok(status),
177                        Err(e) => {
178                            self.state.set_status(format!("Commit failed: {e}"));
179                            self.state.dirty = true;
180                        }
181                    },
182                    InputResult::Continue => self.state.dirty = true,
183                }
184            }
185
186            // Update the spinner state and redraw if in generating mode
187            if self.state.mode == Mode::Generating
188                && self.state.last_spinner_update.elapsed() >= Duration::from_millis(100)
189            {
190                if let Some(spinner) = &mut self.state.spinner {
191                    spinner.tick();
192                    self.state.dirty = true; // Mark dirty to trigger redraw
193                }
194                self.state.last_spinner_update = std::time::Instant::now(); // Reset the update time
195            }
196        }
197
198        Ok(ExitStatus::Cancelled)
199    }
200
201    pub fn handle_regenerate(&mut self) {
202        self.state.mode = Mode::Generating;
203        self.state.spinner = Some(SpinnerState::new());
204    }
205
206    pub fn perform_commit(&self, message: &str) -> Result<ExitStatus, Error> {
207        match self.service.perform_commit(message) {
208            Ok(result) => {
209                let output = format_commit_result(&result, message);
210                Ok(ExitStatus::Committed(output))
211            }
212            Err(e) => Ok(ExitStatus::Error(e.to_string())),
213        }
214    }
215}
216
217#[allow(clippy::unused_async)]
218pub async fn run_tui_commit(
219    initial_messages: Vec<GeneratedMessage>,
220    custom_instructions: String,
221    selected_preset: String,
222    user_name: String,
223    user_email: String,
224    service: Arc<CommitService>,
225) -> Result<()> {
226    TuiCommit::run(
227        initial_messages,
228        custom_instructions,
229        selected_preset,
230        user_name,
231        user_email,
232        service,
233    )
234    .await
235}
236
237pub enum ExitStatus {
238    Committed(String),
239    Cancelled,
240    Error(String),
241}