pitchfork_cli/tui/
mod.rs

1mod app;
2mod event;
3mod ui;
4
5use crate::Result;
6use crate::ipc::client::IpcClient;
7use crossterm::{
8    event::{DisableMouseCapture, EnableMouseCapture},
9    execute,
10    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
11};
12use log::LevelFilter;
13use miette::IntoDiagnostic;
14use ratatui::prelude::*;
15use std::io;
16use std::time::Duration;
17
18pub use app::App;
19
20const REFRESH_RATE: Duration = Duration::from_secs(2);
21const TICK_RATE: Duration = Duration::from_millis(100);
22
23pub async fn run() -> Result<()> {
24    // Suppress terminal logging while TUI is active (logs still go to file)
25    let prev_log_level = log::max_level();
26    log::set_max_level(LevelFilter::Off);
27
28    // Setup terminal
29    enable_raw_mode().into_diagnostic()?;
30    let mut stdout = io::stdout();
31    execute!(stdout, EnterAlternateScreen, EnableMouseCapture).into_diagnostic()?;
32    let backend = CrosstermBackend::new(stdout);
33    let mut terminal = Terminal::new(backend).into_diagnostic()?;
34
35    // Run with cleanup guaranteed
36    let result = run_with_cleanup(&mut terminal).await;
37
38    // Restore terminal (always runs)
39    let _ = disable_raw_mode();
40    let _ = execute!(
41        terminal.backend_mut(),
42        LeaveAlternateScreen,
43        DisableMouseCapture
44    );
45    let _ = terminal.show_cursor();
46
47    // Restore log level
48    log::set_max_level(prev_log_level);
49
50    result
51}
52
53async fn run_with_cleanup<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
54    // Connect to supervisor (auto-start if needed)
55    let client = IpcClient::connect(true).await?;
56
57    // Create app state
58    let mut app = App::new();
59    app.refresh(&client).await?;
60
61    // Run main loop
62    run_app(terminal, &mut app, &client).await
63}
64
65async fn run_app<B: Backend>(
66    terminal: &mut Terminal<B>,
67    app: &mut App,
68    client: &IpcClient,
69) -> Result<()> {
70    let mut last_refresh = std::time::Instant::now();
71
72    loop {
73        // Draw UI
74        terminal.draw(|f| ui::draw(f, app)).into_diagnostic()?;
75
76        // Handle events with timeout
77        if crossterm::event::poll(TICK_RATE).into_diagnostic()?
78            && let Some(action) = event::handle_event(app)?
79        {
80            match action {
81                event::Action::Quit => break,
82                event::Action::Start(id) => {
83                    app.start_loading(format!("Starting {}...", id));
84                    terminal.draw(|f| ui::draw(f, app)).into_diagnostic()?;
85                    // Handle start errors gracefully (don't crash TUI)
86                    if let Err(e) = app.start_daemon(client, &id).await {
87                        app.stop_loading();
88                        app.set_message(format!("Failed to start {}: {}", id, e));
89                    } else {
90                        app.stop_loading();
91                    }
92                    app.refresh(client).await?;
93                }
94                event::Action::Enable(id) => {
95                    app.start_loading(format!("Enabling {}...", id));
96                    terminal.draw(|f| ui::draw(f, app)).into_diagnostic()?;
97                    client.enable(id.clone()).await?;
98                    app.stop_loading();
99                    app.set_message(format!("Enabled {}", id));
100                    app.refresh(client).await?;
101                }
102                event::Action::BatchStart(ids) => {
103                    let count = ids.len();
104                    app.start_loading(format!("Starting {} daemons...", count));
105                    terminal.draw(|f| ui::draw(f, app)).into_diagnostic()?;
106                    let mut started = 0;
107                    for id in &ids {
108                        if app.start_daemon(client, id).await.is_ok() {
109                            started += 1;
110                        }
111                    }
112                    app.stop_loading();
113                    app.clear_selection();
114                    app.set_message(format!("Started {}/{} daemons", started, count));
115                    app.refresh(client).await?;
116                }
117                event::Action::BatchEnable(ids) => {
118                    let count = ids.len();
119                    app.start_loading(format!("Enabling {} daemons...", count));
120                    terminal.draw(|f| ui::draw(f, app)).into_diagnostic()?;
121                    for id in &ids {
122                        let _ = client.enable(id.clone()).await;
123                    }
124                    app.stop_loading();
125                    app.clear_selection();
126                    app.set_message(format!("Enabled {} daemons", count));
127                    app.refresh(client).await?;
128                }
129                event::Action::Refresh => {
130                    app.start_loading("Refreshing...");
131                    terminal.draw(|f| ui::draw(f, app)).into_diagnostic()?;
132                    app.refresh(client).await?;
133                    app.stop_loading();
134                }
135                event::Action::OpenEditorNew => {
136                    app.open_file_selector();
137                }
138                event::Action::OpenEditorEdit(id) => {
139                    app.open_editor_edit(&id);
140                }
141                event::Action::SaveConfig => {
142                    app.start_loading("Saving...");
143                    terminal.draw(|f| ui::draw(f, app)).into_diagnostic()?;
144                    match app.save_editor_config() {
145                        Ok(true) => {
146                            // Successfully saved
147                            app.stop_loading();
148                            app.close_editor();
149                            app.refresh(client).await?;
150                        }
151                        Ok(false) => {
152                            // Validation or duplicate error - don't close editor
153                            app.stop_loading();
154                        }
155                        Err(e) => {
156                            app.stop_loading();
157                            app.set_message(format!("Save failed: {}", e));
158                        }
159                    }
160                }
161                event::Action::DeleteDaemon { id, config_path } => {
162                    app.confirm_action(app::PendingAction::DeleteDaemon { id, config_path });
163                }
164                event::Action::ConfirmPending => {
165                    if let Some(pending) = app.take_pending_action() {
166                        match pending {
167                            app::PendingAction::Stop(id) => {
168                                app.start_loading(format!("Stopping {}...", id));
169                                terminal.draw(|f| ui::draw(f, app)).into_diagnostic()?;
170                                client.stop(id.clone()).await?;
171                                app.stop_loading();
172                                app.set_message(format!("Stopped {}", id));
173                            }
174                            app::PendingAction::Restart(id) => {
175                                app.start_loading(format!("Restarting {}...", id));
176                                terminal.draw(|f| ui::draw(f, app)).into_diagnostic()?;
177                                client.stop(id.clone()).await?;
178                                tokio::time::sleep(Duration::from_millis(500)).await;
179                                // Handle start errors gracefully (don't crash TUI)
180                                if let Err(e) = app.start_daemon(client, &id).await {
181                                    app.stop_loading();
182                                    app.set_message(format!(
183                                        "Stopped {} but failed to restart: {}",
184                                        id, e
185                                    ));
186                                } else {
187                                    app.stop_loading();
188                                    app.set_message(format!("Restarted {}", id));
189                                }
190                            }
191                            app::PendingAction::Disable(id) => {
192                                app.start_loading(format!("Disabling {}...", id));
193                                terminal.draw(|f| ui::draw(f, app)).into_diagnostic()?;
194                                client.disable(id.clone()).await?;
195                                app.stop_loading();
196                                app.set_message(format!("Disabled {}", id));
197                            }
198                            app::PendingAction::BatchStop(ids) => {
199                                let count = ids.len();
200                                app.start_loading(format!("Stopping {} daemons...", count));
201                                terminal.draw(|f| ui::draw(f, app)).into_diagnostic()?;
202                                for id in &ids {
203                                    let _ = client.stop(id.clone()).await;
204                                }
205                                app.stop_loading();
206                                app.clear_selection();
207                                app.set_message(format!("Stopped {} daemons", count));
208                            }
209                            app::PendingAction::BatchRestart(ids) => {
210                                let count = ids.len();
211                                app.start_loading(format!("Restarting {} daemons...", count));
212                                terminal.draw(|f| ui::draw(f, app)).into_diagnostic()?;
213                                // Stop all first
214                                for id in &ids {
215                                    let _ = client.stop(id.clone()).await;
216                                }
217                                tokio::time::sleep(Duration::from_millis(500)).await;
218                                // Start all
219                                let mut started = 0;
220                                for id in &ids {
221                                    if app.start_daemon(client, id).await.is_ok() {
222                                        started += 1;
223                                    }
224                                }
225                                app.stop_loading();
226                                app.clear_selection();
227                                app.set_message(format!("Restarted {}/{} daemons", started, count));
228                            }
229                            app::PendingAction::BatchDisable(ids) => {
230                                let count = ids.len();
231                                app.start_loading(format!("Disabling {} daemons...", count));
232                                terminal.draw(|f| ui::draw(f, app)).into_diagnostic()?;
233                                for id in &ids {
234                                    let _ = client.disable(id.clone()).await;
235                                }
236                                app.stop_loading();
237                                app.clear_selection();
238                                app.set_message(format!("Disabled {} daemons", count));
239                            }
240                            app::PendingAction::DeleteDaemon { id, config_path } => {
241                                app.start_loading(format!("Deleting {}...", id));
242                                terminal.draw(|f| ui::draw(f, app)).into_diagnostic()?;
243                                match app.delete_daemon_from_config(&id, &config_path) {
244                                    Ok(true) => {
245                                        app.stop_loading();
246                                        app.close_editor();
247                                        app.set_message(format!("Deleted {}", id));
248                                    }
249                                    Ok(false) => {
250                                        app.stop_loading();
251                                        app.set_message(format!(
252                                            "Daemon '{}' not found in config",
253                                            id
254                                        ));
255                                    }
256                                    Err(e) => {
257                                        app.stop_loading();
258                                        app.set_message(format!("Delete failed: {}", e));
259                                    }
260                                }
261                            }
262                            app::PendingAction::DiscardEditorChanges => {
263                                app.close_editor();
264                            }
265                        }
266                        app.refresh(client).await?;
267                    }
268                }
269            }
270        }
271
272        // Auto-refresh daemon list
273        if last_refresh.elapsed() >= REFRESH_RATE {
274            app.refresh(client).await?;
275            last_refresh = std::time::Instant::now();
276        }
277    }
278
279    Ok(())
280}