Skip to main content

pitchfork_cli/tui/
mod.rs

1mod app;
2mod event;
3mod ui;
4
5use crate::Result;
6use crate::daemon_id::DaemonId;
7use crate::daemon_list::DaemonListEntry;
8use crate::ipc::batch::{StartOptions, StartResult, StopResult};
9use crate::ipc::client::IpcClient;
10use crate::settings::settings;
11use crossterm::{
12    event::{DisableMouseCapture, EnableMouseCapture},
13    execute,
14    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
15};
16use log::LevelFilter;
17use miette::IntoDiagnostic;
18use ratatui::prelude::*;
19use std::io;
20use std::sync::Arc;
21
22pub use app::App;
23
24/// Results sent back from background IPC tasks to the main loop.
25enum TaskResult {
26    Start {
27        id: DaemonId,
28        result: crate::Result<StartResult>,
29    },
30    Stop {
31        id: DaemonId,
32        result: crate::Result<bool>,
33    },
34    Restart {
35        id: DaemonId,
36        result: crate::Result<StartResult>,
37    },
38    Enable {
39        id: DaemonId,
40        result: crate::Result<bool>,
41    },
42    Disable {
43        id: DaemonId,
44        result: crate::Result<bool>,
45    },
46    BatchStart {
47        count: usize,
48        result: crate::Result<StartResult>,
49    },
50    BatchStop {
51        count: usize,
52        result: crate::Result<StopResult>,
53    },
54    BatchRestart {
55        count: usize,
56        result: crate::Result<StartResult>,
57    },
58    BatchEnable {
59        count: usize,
60    },
61    BatchDisable {
62        count: usize,
63    },
64    Refresh {
65        result: crate::Result<Vec<DaemonListEntry>>,
66        /// Whether this refresh completing should clear `in_flight`.
67        /// True only when the refresh was spawned as the final step of an IPC
68        /// operation (start/stop/etc.) or a manual refresh (Action::Refresh).
69        /// False for background auto-refresh and local-only actions (SaveConfig,
70        /// DeleteDaemon) so they cannot prematurely reset the flag while an IPC
71        /// operation is still in flight.
72        clears_in_flight: bool,
73    },
74    RefreshNetwork(Vec<listeners::Listener>),
75}
76
77pub async fn run() -> Result<()> {
78    // Suppress terminal logging while TUI is active (logs still go to file)
79    let prev_log_level = log::max_level();
80    log::set_max_level(LevelFilter::Off);
81
82    // Setup terminal
83    enable_raw_mode().into_diagnostic()?;
84    let mut stdout = io::stdout();
85    execute!(stdout, EnterAlternateScreen, EnableMouseCapture).into_diagnostic()?;
86    let backend = CrosstermBackend::new(stdout);
87    let mut terminal = Terminal::new(backend).into_diagnostic()?;
88
89    // Run with cleanup guaranteed
90    let result = run_with_cleanup(&mut terminal).await;
91
92    // Restore terminal (always runs)
93    let _ = disable_raw_mode();
94    let _ = execute!(
95        terminal.backend_mut(),
96        LeaveAlternateScreen,
97        DisableMouseCapture
98    );
99    let _ = terminal.show_cursor();
100
101    // Restore log level
102    log::set_max_level(prev_log_level);
103
104    result
105}
106
107async fn run_with_cleanup(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
108    // Connect to supervisor (auto-start if needed)
109    let client = Arc::new(IpcClient::connect(true).await?);
110
111    // Create app state
112    let mut app = App::new();
113    app.refresh(&client).await?;
114
115    // Run main loop
116    run_app(terminal, &mut app, &client).await
117}
118
119async fn run_app(
120    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
121    app: &mut App,
122    client: &Arc<IpcClient>,
123) -> Result<()> {
124    let s = settings();
125    let tick_rate = s.tui_tick_rate();
126    let refresh_rate = s.tui_refresh_rate();
127    let mut last_refresh = std::time::Instant::now();
128
129    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<TaskResult>();
130    // True while an IPC operation (start/stop/etc.) is in flight.
131    // Used to prevent overlapping operations. Navigation and other local
132    // actions are always allowed.
133    let mut in_flight = false;
134
135    loop {
136        // Draw UI
137        terminal.draw(|f| ui::draw(f, app)).into_diagnostic()?;
138
139        // Drain completed background task results
140        while let Ok(result) = rx.try_recv() {
141            match result {
142                TaskResult::Start { id, result } => {
143                    app.stop_loading();
144                    in_flight = false;
145                    match result {
146                        Ok(r) if r.any_failed => {
147                            app.set_message(format!("Failed to start {id}"));
148                        }
149                        Ok(r) if !r.started.is_empty() => {
150                            app.set_message(format!("Started {id}"));
151                        }
152                        Ok(_) => {
153                            app.set_message(format!("No daemons were started for {id}"));
154                        }
155                        Err(e) => {
156                            app.set_message(format!("Failed to start {id}: {e}"));
157                        }
158                    }
159                    spawn_refresh(Arc::clone(client), tx.clone(), false);
160                }
161                TaskResult::Stop { id, result } => {
162                    app.stop_loading();
163                    in_flight = false;
164                    match result {
165                        Ok(true) => app.set_message(format!("Stopped {id}")),
166                        Ok(false) => app.set_message(format!("Daemon {id} was not running")),
167                        Err(e) => app.set_message(format!("Failed to stop {id}: {e}")),
168                    }
169                    spawn_refresh(Arc::clone(client), tx.clone(), false);
170                }
171                TaskResult::Restart { id, result } => {
172                    app.stop_loading();
173                    in_flight = false;
174                    match result {
175                        Ok(r) if r.any_failed => {
176                            app.set_message(format!("Failed to restart {id}"));
177                        }
178                        Ok(_) => {
179                            app.set_message(format!("Restarted {id}"));
180                        }
181                        Err(e) => {
182                            app.set_message(format!("Failed to restart {id}: {e}"));
183                        }
184                    }
185                    spawn_refresh(Arc::clone(client), tx.clone(), false);
186                }
187                TaskResult::Enable { id, result } => {
188                    app.stop_loading();
189                    in_flight = false;
190                    match result {
191                        Ok(_) => app.set_message(format!("Enabled {id}")),
192                        Err(e) => app.set_message(format!("Failed to enable {id}: {e}")),
193                    }
194                    spawn_refresh(Arc::clone(client), tx.clone(), false);
195                }
196                TaskResult::Disable { id, result } => {
197                    app.stop_loading();
198                    in_flight = false;
199                    match result {
200                        Ok(_) => app.set_message(format!("Disabled {id}")),
201                        Err(e) => app.set_message(format!("Failed to disable {id}: {e}")),
202                    }
203                    spawn_refresh(Arc::clone(client), tx.clone(), false);
204                }
205                TaskResult::BatchStart { count, result } => {
206                    app.stop_loading();
207                    in_flight = false;
208                    app.clear_selection();
209                    match result {
210                        Ok(r) => {
211                            let started = r.started.len();
212                            if r.any_failed {
213                                app.set_message(format!(
214                                    "Started {started}/{count} daemons (some failed)"
215                                ));
216                            } else {
217                                app.set_message(format!("Started {started} daemons"));
218                            }
219                        }
220                        Err(e) => {
221                            app.set_message(format!("Failed to start daemons: {e}"));
222                        }
223                    }
224                    spawn_refresh(Arc::clone(client), tx.clone(), false);
225                }
226                TaskResult::BatchStop { count, result } => {
227                    app.stop_loading();
228                    in_flight = false;
229                    app.clear_selection();
230                    match result {
231                        Ok(r) if r.any_failed => {
232                            app.set_message(format!("Stopped {count} daemons (some failed)"));
233                        }
234                        Ok(_) => {
235                            app.set_message(format!("Stopped {count} daemons"));
236                        }
237                        Err(e) => {
238                            app.set_message(format!("Failed to stop daemons: {e}"));
239                        }
240                    }
241                    spawn_refresh(Arc::clone(client), tx.clone(), false);
242                }
243                TaskResult::BatchRestart { count, result } => {
244                    app.stop_loading();
245                    in_flight = false;
246                    app.clear_selection();
247                    match result {
248                        Ok(r) => {
249                            let restarted = r.started.len();
250                            if r.any_failed {
251                                app.set_message(format!(
252                                    "Restarted {restarted}/{count} daemons (some failed)"
253                                ));
254                            } else {
255                                app.set_message(format!("Restarted {restarted} daemons"));
256                            }
257                        }
258                        Err(e) => {
259                            app.set_message(format!("Failed to restart daemons: {e}"));
260                        }
261                    }
262                    spawn_refresh(Arc::clone(client), tx.clone(), false);
263                }
264                TaskResult::BatchEnable { count } => {
265                    app.stop_loading();
266                    in_flight = false;
267                    app.clear_selection();
268                    app.set_message(format!("Enabled {count} daemons"));
269                    spawn_refresh(Arc::clone(client), tx.clone(), false);
270                }
271                TaskResult::BatchDisable { count } => {
272                    app.stop_loading();
273                    in_flight = false;
274                    app.clear_selection();
275                    app.set_message(format!("Disabled {count} daemons"));
276                    spawn_refresh(Arc::clone(client), tx.clone(), false);
277                }
278                TaskResult::Refresh {
279                    result,
280                    clears_in_flight,
281                } => {
282                    if clears_in_flight {
283                        app.stop_loading();
284                        in_flight = false;
285                    }
286                    match result {
287                        Ok(entries) => app.apply_refresh(entries),
288                        Err(e) => app.set_message(format!("Refresh failed: {e}")),
289                    }
290                    last_refresh = std::time::Instant::now();
291                }
292                TaskResult::RefreshNetwork(listeners) => {
293                    app.apply_network_refresh(listeners);
294                }
295            }
296        }
297
298        // Handle events with timeout
299        if crossterm::event::poll(tick_rate).into_diagnostic()?
300            && let Some(action) = event::handle_event(app)?
301        {
302            match action {
303                event::Action::Quit => break,
304                event::Action::Start(id) if !in_flight => {
305                    in_flight = true;
306                    app.start_loading(format!("Starting {id}..."));
307                    let client = Arc::clone(client);
308                    let tx = tx.clone();
309                    tokio::spawn(async move {
310                        let result = client
311                            .start_daemons(
312                                std::slice::from_ref(&id),
313                                StartOptions {
314                                    quiet: true,
315                                    ..StartOptions::default()
316                                },
317                            )
318                            .await;
319                        let _ = tx.send(TaskResult::Start { id, result });
320                    });
321                }
322                event::Action::Enable(id) if !in_flight => {
323                    in_flight = true;
324                    app.start_loading(format!("Enabling {id}..."));
325                    let client = Arc::clone(client);
326                    let tx = tx.clone();
327                    tokio::spawn(async move {
328                        let result = client.enable(id.clone()).await;
329                        let _ = tx.send(TaskResult::Enable { id, result });
330                    });
331                }
332                event::Action::BatchStart(ids) if !in_flight => {
333                    let count = ids.len();
334                    in_flight = true;
335                    app.start_loading(format!("Starting {count} daemons..."));
336                    let client = Arc::clone(client);
337                    let tx = tx.clone();
338                    tokio::spawn(async move {
339                        let result = client
340                            .start_daemons(
341                                &ids,
342                                StartOptions {
343                                    quiet: true,
344                                    ..StartOptions::default()
345                                },
346                            )
347                            .await;
348                        let _ = tx.send(TaskResult::BatchStart { count, result });
349                    });
350                }
351                event::Action::BatchEnable(ids) if !in_flight => {
352                    let count = ids.len();
353                    in_flight = true;
354                    app.start_loading(format!("Enabling {count} daemons..."));
355                    let client = Arc::clone(client);
356                    let tx = tx.clone();
357                    tokio::spawn(async move {
358                        for id in &ids {
359                            let _ = client.enable(id.clone()).await;
360                        }
361                        let _ = tx.send(TaskResult::BatchEnable { count });
362                    });
363                }
364                event::Action::Refresh if !in_flight => {
365                    in_flight = true;
366                    app.start_loading("Refreshing...");
367                    spawn_refresh(Arc::clone(client), tx.clone(), true);
368                }
369                event::Action::OpenEditorNew => {
370                    app.open_file_selector();
371                }
372                event::Action::OpenEditorEdit(id) => {
373                    app.open_editor_edit(&id);
374                }
375                event::Action::SaveConfig => {
376                    app.start_loading("Saving...");
377                    match app.save_editor_config() {
378                        Ok(true) => {
379                            app.stop_loading();
380                            app.close_editor();
381                            spawn_refresh(Arc::clone(client), tx.clone(), false);
382                        }
383                        Ok(false) => {
384                            app.stop_loading();
385                        }
386                        Err(e) => {
387                            app.stop_loading();
388                            app.set_message(format!("Save failed: {e}"));
389                        }
390                    }
391                }
392                event::Action::DeleteDaemon { id, config_path } => {
393                    app.confirm_action(app::PendingAction::DeleteDaemon { id, config_path });
394                }
395                event::Action::ConfirmPending if !in_flight => {
396                    if let Some(pending) = app.take_pending_action() {
397                        match pending {
398                            app::PendingAction::Stop(id) => {
399                                in_flight = true;
400                                app.start_loading(format!("Stopping {id}..."));
401                                let client = Arc::clone(client);
402                                let tx = tx.clone();
403                                tokio::spawn(async move {
404                                    let result = client.stop(id.clone()).await;
405                                    let _ = tx.send(TaskResult::Stop { id, result });
406                                });
407                            }
408                            app::PendingAction::Restart(id) => {
409                                in_flight = true;
410                                app.start_loading(format!("Restarting {id}..."));
411                                let client = Arc::clone(client);
412                                let tx = tx.clone();
413                                tokio::spawn(async move {
414                                    let opts = StartOptions {
415                                        force: true,
416                                        quiet: true,
417                                        ..Default::default()
418                                    };
419                                    let result =
420                                        client.start_daemons(std::slice::from_ref(&id), opts).await;
421                                    let _ = tx.send(TaskResult::Restart { id, result });
422                                });
423                            }
424                            app::PendingAction::Disable(id) => {
425                                in_flight = true;
426                                app.start_loading(format!("Disabling {id}..."));
427                                let client = Arc::clone(client);
428                                let tx = tx.clone();
429                                tokio::spawn(async move {
430                                    let result = client.disable(id.clone()).await;
431                                    let _ = tx.send(TaskResult::Disable { id, result });
432                                });
433                            }
434                            app::PendingAction::BatchStop(ids) => {
435                                let count = ids.len();
436                                in_flight = true;
437                                app.start_loading(format!("Stopping {count} daemons..."));
438                                let client = Arc::clone(client);
439                                let tx = tx.clone();
440                                tokio::spawn(async move {
441                                    let result = client.stop_daemons(&ids).await;
442                                    let _ = tx.send(TaskResult::BatchStop { count, result });
443                                });
444                            }
445                            app::PendingAction::BatchRestart(ids) => {
446                                let count = ids.len();
447                                in_flight = true;
448                                app.start_loading(format!("Restarting {count} daemons..."));
449                                let client = Arc::clone(client);
450                                let tx = tx.clone();
451                                tokio::spawn(async move {
452                                    let opts = StartOptions {
453                                        force: true,
454                                        quiet: true,
455                                        ..Default::default()
456                                    };
457                                    let result = client.start_daemons(&ids, opts).await;
458                                    let _ = tx.send(TaskResult::BatchRestart { count, result });
459                                });
460                            }
461                            app::PendingAction::BatchDisable(ids) => {
462                                let count = ids.len();
463                                in_flight = true;
464                                app.start_loading(format!("Disabling {count} daemons..."));
465                                let client = Arc::clone(client);
466                                let tx = tx.clone();
467                                tokio::spawn(async move {
468                                    for id in &ids {
469                                        let _ = client.disable(id.clone()).await;
470                                    }
471                                    let _ = tx.send(TaskResult::BatchDisable { count });
472                                });
473                            }
474                            app::PendingAction::DeleteDaemon { id, config_path } => {
475                                app.start_loading(format!("Deleting {id}..."));
476                                match app.delete_daemon_from_config(&id, &config_path) {
477                                    Ok(true) => {
478                                        app.stop_loading();
479                                        app.close_editor();
480                                        app.set_message(format!("Deleted {id}"));
481                                    }
482                                    Ok(false) => {
483                                        app.stop_loading();
484                                        app.set_message(format!(
485                                            "Daemon '{id}' not found in config"
486                                        ));
487                                    }
488                                    Err(e) => {
489                                        app.stop_loading();
490                                        app.set_message(format!("Delete failed: {e}"));
491                                    }
492                                }
493                                spawn_refresh(Arc::clone(client), tx.clone(), false);
494                            }
495                            app::PendingAction::DiscardEditorChanges => {
496                                app.close_editor();
497                            }
498                        }
499                    }
500                }
501                // Ignore IPC actions when in_flight (navigation/local actions fall through)
502                _ => {}
503            }
504        }
505
506        // Auto-refresh daemon list (skip if IPC operation in flight)
507        if last_refresh.elapsed() >= refresh_rate && !in_flight {
508            let is_network = app.view == app::View::Network;
509            let client_ref = Arc::clone(client);
510            let tx_ref = tx.clone();
511            tokio::spawn(async move {
512                let entries = App::fetch_daemon_data(&client_ref).await;
513                let _ = tx_ref.send(TaskResult::Refresh {
514                    result: entries,
515                    clears_in_flight: false,
516                });
517                if is_network {
518                    let listeners = tokio::task::spawn_blocking(|| {
519                        listeners::get_all()
520                            .map(|set| set.into_iter().collect::<Vec<_>>())
521                            .unwrap_or_default()
522                    })
523                    .await
524                    .unwrap_or_default();
525                    let _ = tx_ref.send(TaskResult::RefreshNetwork(listeners));
526                }
527            });
528            // Optimistically advance the timer so we don't spam refreshes
529            last_refresh = std::time::Instant::now();
530        }
531    }
532
533    Ok(())
534}
535
536fn spawn_refresh(
537    client: Arc<IpcClient>,
538    tx: tokio::sync::mpsc::UnboundedSender<TaskResult>,
539    clears_in_flight: bool,
540) {
541    tokio::spawn(async move {
542        let entries = App::fetch_daemon_data(&client).await;
543        let _ = tx.send(TaskResult::Refresh {
544            result: entries,
545            clears_in_flight,
546        });
547    });
548}