Skip to main content

pitchfork_cli/tui/
mod.rs

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