git_iris/tui/
app.rs

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