git_iris/tui/
app.rs

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