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