Skip to main content

purple_ssh/
event.rs

1use std::sync::atomic::{AtomicBool, Ordering};
2use std::sync::{Arc, mpsc};
3use std::thread;
4use std::time::{Duration, Instant};
5
6use anyhow::Result;
7use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, KeyEventKind};
8
9/// Application events.
10pub enum AppEvent {
11    Key(KeyEvent),
12    Tick,
13    PingResult {
14        alias: String,
15        rtt_ms: Option<u32>,
16        generation: u64,
17    },
18    SyncComplete {
19        provider: String,
20        hosts: Vec<crate::providers::ProviderHost>,
21    },
22    SyncPartial {
23        provider: String,
24        hosts: Vec<crate::providers::ProviderHost>,
25        failures: usize,
26        total: usize,
27    },
28    SyncError {
29        provider: String,
30        message: String,
31    },
32    SyncProgress {
33        provider: String,
34        message: String,
35    },
36    UpdateAvailable {
37        version: String,
38        headline: Option<String>,
39    },
40    FileBrowserListing {
41        alias: String,
42        path: String,
43        entries: Result<Vec<crate::file_browser::FileEntry>, String>,
44    },
45    ScpComplete {
46        alias: String,
47        success: bool,
48        message: String,
49    },
50    SnippetHostDone {
51        run_id: u64,
52        alias: String,
53        stdout: String,
54        stderr: String,
55        exit_code: Option<i32>,
56    },
57    SnippetAllDone {
58        run_id: u64,
59    },
60    SnippetProgress {
61        run_id: u64,
62        completed: usize,
63        total: usize,
64    },
65    ContainerListing {
66        alias: String,
67        result: Result<crate::containers::ContainerListing, crate::containers::ContainerError>,
68    },
69    ContainerActionComplete {
70        alias: String,
71        action: crate::containers::ContainerAction,
72        result: Result<(), String>,
73    },
74    /// Result of a `docker inspect` (or `podman inspect`) call fired by
75    /// the containers overview detail panel. Cached per (alias,
76    /// container_id) once received so repeat fetches inside the TTL
77    /// window are skipped.
78    ContainerInspectComplete {
79        alias: String,
80        container_id: String,
81        // Boxed because `ContainerInspect` carries the full audit
82        // payload (caps, mounts, compose labels). Inlining it grows the
83        // `AppEvent` enum past clippy's `large_enum_variant` budget,
84        // bloating every queue slot for events that do not carry it.
85        result: Box<Result<crate::containers::ContainerInspect, String>>,
86    },
87    /// Result of `<runtime> logs --tail 200` over SSH for a container
88    /// the user opened with `l`. Populates `Screen::ContainerLogs.body`
89    /// (or `.error`) when received.
90    ContainerLogsComplete {
91        alias: String,
92        container_id: String,
93        container_name: String,
94        result: Result<Vec<String>, String>,
95    },
96    /// Result of a short `<runtime> logs --tail N` fetch fired by the
97    /// containers-overview detail panel to populate the LOGS card.
98    /// Distinct from `ContainerLogsComplete` so the dedicated logs
99    /// viewer (`l`) and the detail-panel card stay on separate caches.
100    ContainerLogsTailComplete {
101        alias: String,
102        container_id: String,
103        result: Box<Result<Vec<String>, String>>,
104    },
105    VaultSignResult {
106        alias: String,
107        /// Snapshot of the host's `CertificateFile` directive at signing time.
108        /// Carried in the event so the main loop never has to re-look up the
109        /// host (which would be O(n) and racy under concurrent renames). Empty
110        /// when the host has no `CertificateFile` set; `should_write_certificate_file`
111        /// uses this directly to decide whether to write a default directive.
112        certificate_file: String,
113        success: bool,
114        message: String,
115    },
116    VaultSignProgress {
117        alias: String,
118        done: usize,
119        total: usize,
120    },
121    VaultSignAllDone {
122        signed: u32,
123        failed: u32,
124        skipped: u32,
125        cancelled: bool,
126        aborted_message: Option<String>,
127        first_error: Option<String>,
128    },
129    CertCheckResult {
130        alias: String,
131        status: crate::vault_ssh::CertStatus,
132    },
133    CertCheckError {
134        alias: String,
135        message: String,
136    },
137    PollError,
138}
139
140/// Polls crossterm events in a background thread.
141pub struct EventHandler {
142    tx: mpsc::Sender<AppEvent>,
143    rx: mpsc::Receiver<AppEvent>,
144    paused: Arc<AtomicBool>,
145    // Keep the thread handle alive
146    _handle: thread::JoinHandle<()>,
147}
148
149impl EventHandler {
150    pub fn new(tick_rate_ms: u64) -> Self {
151        let (tx, rx) = mpsc::channel();
152        let tick_rate = Duration::from_millis(tick_rate_ms);
153        let event_tx = tx.clone();
154        let paused = Arc::new(AtomicBool::new(false));
155        let paused_flag = paused.clone();
156
157        let handle = thread::spawn(move || {
158            let mut last_tick = Instant::now();
159            loop {
160                // When paused, sleep instead of polling stdin
161                if paused_flag.load(Ordering::Acquire) {
162                    thread::sleep(Duration::from_millis(50));
163                    continue;
164                }
165
166                // Cap poll timeout at 50ms so we notice pause flag quickly
167                let remaining = tick_rate
168                    .checked_sub(last_tick.elapsed())
169                    .unwrap_or(Duration::ZERO);
170                let timeout = remaining.min(Duration::from_millis(50));
171
172                match event::poll(timeout) {
173                    Ok(true) => {
174                        if let Ok(evt) = event::read() {
175                            match evt {
176                                CrosstermEvent::Key(key)
177                                    if key.kind == KeyEventKind::Press
178                                        && event_tx.send(AppEvent::Key(key)).is_err() =>
179                                {
180                                    return;
181                                }
182                                // Trigger immediate redraw on terminal resize.
183                                CrosstermEvent::Resize(..)
184                                    if event_tx.send(AppEvent::Tick).is_err() =>
185                                {
186                                    return;
187                                }
188                                _ => {}
189                            }
190                        }
191                    }
192                    Ok(false) => {}
193                    Err(_) => {
194                        // Poll error (e.g. stdin closed). Notify main loop and exit.
195                        let _ = event_tx.send(AppEvent::PollError);
196                        return;
197                    }
198                }
199
200                if last_tick.elapsed() >= tick_rate {
201                    if event_tx.send(AppEvent::Tick).is_err() {
202                        return;
203                    }
204                    last_tick = Instant::now();
205                }
206            }
207        });
208
209        Self {
210            tx,
211            rx,
212            paused,
213            _handle: handle,
214        }
215    }
216
217    /// Get the next event (blocks until available).
218    pub fn next(&self) -> Result<AppEvent> {
219        Ok(self.rx.recv()?)
220    }
221
222    /// Try to get the next event with a timeout.
223    pub fn next_timeout(&self, timeout: Duration) -> Result<Option<AppEvent>> {
224        match self.rx.recv_timeout(timeout) {
225            Ok(event) => Ok(Some(event)),
226            Err(mpsc::RecvTimeoutError::Timeout) => Ok(None),
227            Err(mpsc::RecvTimeoutError::Disconnected) => {
228                Err(anyhow::anyhow!("event channel disconnected"))
229            }
230        }
231    }
232
233    /// Get a clone of the sender for sending events from other threads.
234    pub fn sender(&self) -> mpsc::Sender<AppEvent> {
235        self.tx.clone()
236    }
237
238    /// Pause event polling (call before spawning SSH).
239    pub fn pause(&self) {
240        self.paused.store(true, Ordering::Release);
241    }
242
243    /// Resume event polling (call after SSH exits).
244    pub fn resume(&self) {
245        // Drain stale events, but keep background result events
246        let mut preserved = Vec::new();
247        while let Ok(event) = self.rx.try_recv() {
248            match event {
249                AppEvent::PingResult { .. }
250                | AppEvent::SyncComplete { .. }
251                | AppEvent::SyncPartial { .. }
252                | AppEvent::SyncError { .. }
253                | AppEvent::SyncProgress { .. }
254                | AppEvent::UpdateAvailable { .. }
255                | AppEvent::FileBrowserListing { .. }
256                | AppEvent::ScpComplete { .. }
257                | AppEvent::SnippetHostDone { .. }
258                | AppEvent::SnippetAllDone { .. }
259                | AppEvent::SnippetProgress { .. }
260                | AppEvent::ContainerListing { .. }
261                | AppEvent::ContainerActionComplete { .. }
262                | AppEvent::ContainerInspectComplete { .. }
263                | AppEvent::ContainerLogsComplete { .. }
264                | AppEvent::ContainerLogsTailComplete { .. }
265                | AppEvent::VaultSignResult { .. }
266                | AppEvent::VaultSignProgress { .. }
267                | AppEvent::VaultSignAllDone { .. }
268                | AppEvent::CertCheckResult { .. }
269                | AppEvent::CertCheckError { .. } => preserved.push(event),
270                _ => {}
271            }
272        }
273        // Re-send preserved events
274        for event in preserved {
275            let _ = self.tx.send(event);
276        }
277        self.paused.store(false, Ordering::Release);
278    }
279}