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