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, NamespaceFilter};
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(namespace_filter: NamespaceFilter) -> 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, namespace_filter).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(
108    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
109    namespace_filter: NamespaceFilter,
110) -> Result<()> {
111    // Connect to supervisor (auto-start if needed)
112    let client = Arc::new(IpcClient::connect(true).await?);
113
114    // Create app state
115    let mut app = App::new(namespace_filter);
116    app.refresh(&client).await?;
117
118    // Run main loop
119    run_app(terminal, &mut app, &client).await
120}
121
122async fn run_app(
123    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
124    app: &mut App,
125    client: &Arc<IpcClient>,
126) -> Result<()> {
127    let s = settings();
128    let tick_rate = s.tui_tick_rate();
129    let refresh_rate = s.tui_refresh_rate();
130    let mut last_refresh = std::time::Instant::now();
131
132    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<TaskResult>();
133    // True while an IPC operation (start/stop/etc.) is in flight.
134    // Used to prevent overlapping operations. Navigation and other local
135    // actions are always allowed.
136    let mut in_flight = false;
137
138    loop {
139        // Draw UI
140        terminal.draw(|f| ui::draw(f, app)).into_diagnostic()?;
141
142        // Drain completed background task results
143        while let Ok(result) = rx.try_recv() {
144            match result {
145                TaskResult::Start { id, result } => {
146                    app.stop_loading();
147                    in_flight = false;
148                    match result {
149                        Ok(r) if r.any_failed => {
150                            app.set_message(format!("Failed to start {id}"));
151                        }
152                        Ok(r) if !r.started.is_empty() => {
153                            app.set_message(format!("Started {id}"));
154                        }
155                        Ok(_) => {
156                            app.set_message(format!("No daemons were started for {id}"));
157                        }
158                        Err(e) => {
159                            app.set_message(format!("Failed to start {id}: {e}"));
160                        }
161                    }
162                    spawn_refresh(
163                        Arc::clone(client),
164                        tx.clone(),
165                        false,
166                        app.namespace_filter.clone(),
167                    );
168                }
169                TaskResult::Stop { id, result } => {
170                    app.stop_loading();
171                    in_flight = false;
172                    match result {
173                        Ok(true) => app.set_message(format!("Stopped {id}")),
174                        Ok(false) => app.set_message(format!("Daemon {id} was not running")),
175                        Err(e) => app.set_message(format!("Failed to stop {id}: {e}")),
176                    }
177                    spawn_refresh(
178                        Arc::clone(client),
179                        tx.clone(),
180                        false,
181                        app.namespace_filter.clone(),
182                    );
183                }
184                TaskResult::Restart { id, result } => {
185                    app.stop_loading();
186                    in_flight = false;
187                    match result {
188                        Ok(r) if r.any_failed => {
189                            app.set_message(format!("Failed to restart {id}"));
190                        }
191                        Ok(_) => {
192                            app.set_message(format!("Restarted {id}"));
193                        }
194                        Err(e) => {
195                            app.set_message(format!("Failed to restart {id}: {e}"));
196                        }
197                    }
198                    spawn_refresh(
199                        Arc::clone(client),
200                        tx.clone(),
201                        false,
202                        app.namespace_filter.clone(),
203                    );
204                }
205                TaskResult::Enable { id, result } => {
206                    app.stop_loading();
207                    in_flight = false;
208                    match result {
209                        Ok(_) => app.set_message(format!("Enabled {id}")),
210                        Err(e) => app.set_message(format!("Failed to enable {id}: {e}")),
211                    }
212                    spawn_refresh(
213                        Arc::clone(client),
214                        tx.clone(),
215                        false,
216                        app.namespace_filter.clone(),
217                    );
218                }
219                TaskResult::Disable { id, result } => {
220                    app.stop_loading();
221                    in_flight = false;
222                    match result {
223                        Ok(_) => app.set_message(format!("Disabled {id}")),
224                        Err(e) => app.set_message(format!("Failed to disable {id}: {e}")),
225                    }
226                    spawn_refresh(
227                        Arc::clone(client),
228                        tx.clone(),
229                        false,
230                        app.namespace_filter.clone(),
231                    );
232                }
233                TaskResult::BatchStart { count, result } => {
234                    app.stop_loading();
235                    in_flight = false;
236                    app.clear_selection();
237                    match result {
238                        Ok(r) => {
239                            let started = r.started.len();
240                            if r.any_failed {
241                                app.set_message(format!(
242                                    "Started {started}/{count} daemons (some failed)"
243                                ));
244                            } else {
245                                app.set_message(format!("Started {started} daemons"));
246                            }
247                        }
248                        Err(e) => {
249                            app.set_message(format!("Failed to start daemons: {e}"));
250                        }
251                    }
252                    spawn_refresh(
253                        Arc::clone(client),
254                        tx.clone(),
255                        false,
256                        app.namespace_filter.clone(),
257                    );
258                }
259                TaskResult::BatchStop { count, result } => {
260                    app.stop_loading();
261                    in_flight = false;
262                    app.clear_selection();
263                    match result {
264                        Ok(r) if r.any_failed => {
265                            app.set_message(format!("Stopped {count} daemons (some failed)"));
266                        }
267                        Ok(_) => {
268                            app.set_message(format!("Stopped {count} daemons"));
269                        }
270                        Err(e) => {
271                            app.set_message(format!("Failed to stop daemons: {e}"));
272                        }
273                    }
274                    spawn_refresh(
275                        Arc::clone(client),
276                        tx.clone(),
277                        false,
278                        app.namespace_filter.clone(),
279                    );
280                }
281                TaskResult::BatchRestart { count, result } => {
282                    app.stop_loading();
283                    in_flight = false;
284                    app.clear_selection();
285                    match result {
286                        Ok(r) => {
287                            let restarted = r.started.len();
288                            if r.any_failed {
289                                app.set_message(format!(
290                                    "Restarted {restarted}/{count} daemons (some failed)"
291                                ));
292                            } else {
293                                app.set_message(format!("Restarted {restarted} daemons"));
294                            }
295                        }
296                        Err(e) => {
297                            app.set_message(format!("Failed to restart daemons: {e}"));
298                        }
299                    }
300                    spawn_refresh(
301                        Arc::clone(client),
302                        tx.clone(),
303                        false,
304                        app.namespace_filter.clone(),
305                    );
306                }
307                TaskResult::BatchEnable { count } => {
308                    app.stop_loading();
309                    in_flight = false;
310                    app.clear_selection();
311                    app.set_message(format!("Enabled {count} daemons"));
312                    spawn_refresh(
313                        Arc::clone(client),
314                        tx.clone(),
315                        false,
316                        app.namespace_filter.clone(),
317                    );
318                }
319                TaskResult::BatchDisable { count } => {
320                    app.stop_loading();
321                    in_flight = false;
322                    app.clear_selection();
323                    app.set_message(format!("Disabled {count} daemons"));
324                    spawn_refresh(
325                        Arc::clone(client),
326                        tx.clone(),
327                        false,
328                        app.namespace_filter.clone(),
329                    );
330                }
331                TaskResult::Refresh {
332                    result,
333                    clears_in_flight,
334                } => {
335                    if clears_in_flight {
336                        app.stop_loading();
337                        in_flight = false;
338                    }
339                    match result {
340                        Ok(entries) => app.apply_refresh(entries),
341                        Err(e) => app.set_message(format!("Refresh failed: {e}")),
342                    }
343                    last_refresh = std::time::Instant::now();
344                }
345                TaskResult::RefreshNetwork(listeners) => {
346                    app.apply_network_refresh(listeners);
347                }
348            }
349        }
350
351        // Handle events with timeout
352        if crossterm::event::poll(tick_rate).into_diagnostic()?
353            && let Some(action) = event::handle_event(app)?
354        {
355            match action {
356                event::Action::Quit => break,
357                event::Action::Start(id) if !in_flight => {
358                    in_flight = true;
359                    app.start_loading(format!("Starting {id}..."));
360                    let client = Arc::clone(client);
361                    let tx = tx.clone();
362                    tokio::spawn(async move {
363                        let result = client
364                            .start_daemons(
365                                std::slice::from_ref(&id),
366                                StartOptions {
367                                    quiet: true,
368                                    ..StartOptions::default()
369                                },
370                            )
371                            .await;
372                        let _ = tx.send(TaskResult::Start { id, result });
373                    });
374                }
375                event::Action::Enable(id) if !in_flight => {
376                    in_flight = true;
377                    app.start_loading(format!("Enabling {id}..."));
378                    let client = Arc::clone(client);
379                    let tx = tx.clone();
380                    tokio::spawn(async move {
381                        let result = client.enable(id.clone()).await;
382                        let _ = tx.send(TaskResult::Enable { id, result });
383                    });
384                }
385                event::Action::BatchStart(ids) if !in_flight => {
386                    let count = ids.len();
387                    in_flight = true;
388                    app.start_loading(format!("Starting {count} daemons..."));
389                    let client = Arc::clone(client);
390                    let tx = tx.clone();
391                    tokio::spawn(async move {
392                        let result = client
393                            .start_daemons(
394                                &ids,
395                                StartOptions {
396                                    quiet: true,
397                                    ..StartOptions::default()
398                                },
399                            )
400                            .await;
401                        let _ = tx.send(TaskResult::BatchStart { count, result });
402                    });
403                }
404                event::Action::BatchEnable(ids) if !in_flight => {
405                    let count = ids.len();
406                    in_flight = true;
407                    app.start_loading(format!("Enabling {count} daemons..."));
408                    let client = Arc::clone(client);
409                    let tx = tx.clone();
410                    tokio::spawn(async move {
411                        for id in &ids {
412                            let _ = client.enable(id.clone()).await;
413                        }
414                        let _ = tx.send(TaskResult::BatchEnable { count });
415                    });
416                }
417                event::Action::Refresh if !in_flight => {
418                    in_flight = true;
419                    app.start_loading("Refreshing...");
420                    spawn_refresh(
421                        Arc::clone(client),
422                        tx.clone(),
423                        true,
424                        app.namespace_filter.clone(),
425                    );
426                }
427                event::Action::OpenEditorNew => {
428                    app.open_file_selector();
429                }
430                event::Action::OpenEditorEdit(id) => {
431                    app.open_editor_edit(&id);
432                }
433                event::Action::SaveConfig => {
434                    app.start_loading("Saving...");
435                    match app.save_editor_config() {
436                        Ok(true) => {
437                            app.stop_loading();
438                            app.close_editor();
439                            spawn_refresh(
440                                Arc::clone(client),
441                                tx.clone(),
442                                false,
443                                app.namespace_filter.clone(),
444                            );
445                        }
446                        Ok(false) => {
447                            app.stop_loading();
448                        }
449                        Err(e) => {
450                            app.stop_loading();
451                            app.set_message(format!("Save failed: {e}"));
452                        }
453                    }
454                }
455                event::Action::DeleteDaemon { id, config_path } => {
456                    app.confirm_action(app::PendingAction::DeleteDaemon { id, config_path });
457                }
458                event::Action::ConfirmPending if !in_flight => {
459                    if let Some(pending) = app.take_pending_action() {
460                        match pending {
461                            app::PendingAction::Stop(id) => {
462                                in_flight = true;
463                                app.start_loading(format!("Stopping {id}..."));
464                                let client = Arc::clone(client);
465                                let tx = tx.clone();
466                                tokio::spawn(async move {
467                                    let result = client.stop(id.clone()).await;
468                                    let _ = tx.send(TaskResult::Stop { id, result });
469                                });
470                            }
471                            app::PendingAction::Restart(id) => {
472                                in_flight = true;
473                                app.start_loading(format!("Restarting {id}..."));
474                                let client = Arc::clone(client);
475                                let tx = tx.clone();
476                                tokio::spawn(async move {
477                                    let opts = StartOptions {
478                                        force: true,
479                                        quiet: true,
480                                        ..Default::default()
481                                    };
482                                    let result =
483                                        client.start_daemons(std::slice::from_ref(&id), opts).await;
484                                    let _ = tx.send(TaskResult::Restart { id, result });
485                                });
486                            }
487                            app::PendingAction::Disable(id) => {
488                                in_flight = true;
489                                app.start_loading(format!("Disabling {id}..."));
490                                let client = Arc::clone(client);
491                                let tx = tx.clone();
492                                tokio::spawn(async move {
493                                    let result = client.disable(id.clone()).await;
494                                    let _ = tx.send(TaskResult::Disable { id, result });
495                                });
496                            }
497                            app::PendingAction::BatchStop(ids) => {
498                                let count = ids.len();
499                                in_flight = true;
500                                app.start_loading(format!("Stopping {count} daemons..."));
501                                let client = Arc::clone(client);
502                                let tx = tx.clone();
503                                tokio::spawn(async move {
504                                    let result = client.stop_daemons(&ids).await;
505                                    let _ = tx.send(TaskResult::BatchStop { count, result });
506                                });
507                            }
508                            app::PendingAction::BatchRestart(ids) => {
509                                let count = ids.len();
510                                in_flight = true;
511                                app.start_loading(format!("Restarting {count} daemons..."));
512                                let client = Arc::clone(client);
513                                let tx = tx.clone();
514                                tokio::spawn(async move {
515                                    let opts = StartOptions {
516                                        force: true,
517                                        quiet: true,
518                                        ..Default::default()
519                                    };
520                                    let result = client.start_daemons(&ids, opts).await;
521                                    let _ = tx.send(TaskResult::BatchRestart { count, result });
522                                });
523                            }
524                            app::PendingAction::BatchDisable(ids) => {
525                                let count = ids.len();
526                                in_flight = true;
527                                app.start_loading(format!("Disabling {count} daemons..."));
528                                let client = Arc::clone(client);
529                                let tx = tx.clone();
530                                tokio::spawn(async move {
531                                    for id in &ids {
532                                        let _ = client.disable(id.clone()).await;
533                                    }
534                                    let _ = tx.send(TaskResult::BatchDisable { count });
535                                });
536                            }
537                            app::PendingAction::DeleteDaemon { id, config_path } => {
538                                app.start_loading(format!("Deleting {id}..."));
539                                match app.delete_daemon_from_config(&id, &config_path) {
540                                    Ok(true) => {
541                                        app.stop_loading();
542                                        app.close_editor();
543                                        app.set_message(format!("Deleted {id}"));
544                                    }
545                                    Ok(false) => {
546                                        app.stop_loading();
547                                        app.set_message(format!(
548                                            "Daemon '{id}' not found in config"
549                                        ));
550                                    }
551                                    Err(e) => {
552                                        app.stop_loading();
553                                        app.set_message(format!("Delete failed: {e}"));
554                                    }
555                                }
556                                spawn_refresh(
557                                    Arc::clone(client),
558                                    tx.clone(),
559                                    false,
560                                    app.namespace_filter.clone(),
561                                );
562                            }
563                            app::PendingAction::DiscardEditorChanges => {
564                                app.close_editor();
565                            }
566                        }
567                    }
568                }
569                // Ignore IPC actions when in_flight (navigation/local actions fall through)
570                _ => {}
571            }
572        }
573
574        // Auto-refresh daemon list (skip if IPC operation in flight)
575        if last_refresh.elapsed() >= refresh_rate && !in_flight {
576            let is_network = app.view == app::View::Network;
577            let client_ref = Arc::clone(client);
578            let tx_ref = tx.clone();
579            let ns_filter = app.namespace_filter.clone();
580            tokio::spawn(async move {
581                let entries = App::fetch_daemon_data(&client_ref, &ns_filter).await;
582                let _ = tx_ref.send(TaskResult::Refresh {
583                    result: entries,
584                    clears_in_flight: false,
585                });
586                if is_network {
587                    let listeners = tokio::task::spawn_blocking(|| {
588                        listeners::get_all()
589                            .map(|set| set.into_iter().collect::<Vec<_>>())
590                            .unwrap_or_default()
591                    })
592                    .await
593                    .unwrap_or_default();
594                    let _ = tx_ref.send(TaskResult::RefreshNetwork(listeners));
595                }
596            });
597            // Optimistically advance the timer so we don't spam refreshes
598            last_refresh = std::time::Instant::now();
599        }
600    }
601
602    Ok(())
603}
604
605fn spawn_refresh(
606    client: Arc<IpcClient>,
607    tx: tokio::sync::mpsc::UnboundedSender<TaskResult>,
608    clears_in_flight: bool,
609    namespace_filter: NamespaceFilter,
610) {
611    tokio::spawn(async move {
612        let entries = App::fetch_daemon_data(&client, &namespace_filter).await;
613        let _ = tx.send(TaskResult::Refresh {
614            result: entries,
615            clears_in_flight,
616        });
617    });
618}