1use super::app::App;
2use super::disk_log_reader::DiskLogReader;
3use super::input;
4use crate::cli;
5use crate::cli::logs::tail_file;
6use crate::paths;
7use crate::protocol::{Request, Response, Stream as ProtoStream};
8use crossterm::event::{Event, EventStream, MouseButton, MouseEventKind};
9use futures::StreamExt;
10use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
11use tokio::sync::mpsc;
12use tokio::time::{Duration, interval};
13
14pub enum AppEvent {
15 Key(crossterm::event::KeyEvent),
16 Mouse(crossterm::event::MouseEvent),
17 OutputLine {
18 process: String,
19 stream: ProtoStream,
20 line: String,
21 },
22 StatusUpdate(Vec<crate::protocol::ProcessInfo>),
23 OutputStreamClosed,
24}
25
26enum ReconnectState {
27 Connected,
28 Reconnecting { attempt: u32 },
29 Failed,
30}
31
32const MAX_RECONNECT_ATTEMPTS: u32 = 10;
33
34pub struct TuiEventLoop {
35 rx: mpsc::Receiver<AppEvent>,
36 tx: mpsc::Sender<AppEvent>,
37 session: String,
38 reconnect_state: ReconnectState,
39}
40
41impl TuiEventLoop {
42 pub fn new(session: &str) -> Self {
43 let (tx, rx) = mpsc::channel::<AppEvent>(256);
44 Self {
45 rx,
46 tx,
47 session: session.to_string(),
48 reconnect_state: ReconnectState::Connected,
49 }
50 }
51
52 pub fn spawn_background_tasks(&self) {
54 let session_str = self.session.clone();
55 let output_tx = self.tx.clone();
56 tokio::spawn(async move {
57 output_stream_reader(&session_str, output_tx).await;
58 });
59
60 let session_str = self.session.clone();
61 let status_tx = self.tx.clone();
62 tokio::spawn(async move {
63 status_poller(&session_str, status_tx).await;
64 });
65
66 let key_tx = self.tx.clone();
67 tokio::spawn(async move {
68 key_reader(key_tx).await;
69 });
70
71 let sigterm_tx = self.tx.clone();
72 tokio::spawn(async move {
73 if let Ok(mut sig) =
74 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
75 {
76 sig.recv().await;
77 let _ = sigterm_tx
78 .send(AppEvent::Key(crossterm::event::KeyEvent::new(
79 crossterm::event::KeyCode::Char('q'),
80 crossterm::event::KeyModifiers::empty(),
81 )))
82 .await;
83 }
84 });
85 }
86
87 pub async fn run(
89 &mut self,
90 terminal: &mut ratatui::Terminal<ratatui::prelude::CrosstermBackend<std::io::Stdout>>,
91 app: &mut App,
92 ) {
93 while app.running {
94 if let Err(e) = terminal.draw(|f| super::ui::draw(f, app)) {
95 eprintln!("error: terminal draw failed: {}", e);
96 break;
97 }
98
99 if let Some(event) = self.rx.recv().await {
100 match event {
101 AppEvent::Key(key) => {
102 self.handle_key(app, key).await;
103 }
104 AppEvent::Mouse(mouse) => {
105 Self::handle_mouse(app, mouse);
106 }
107 AppEvent::OutputLine {
108 process,
109 stream,
110 line,
111 } => {
112 self.reconnect_state = ReconnectState::Connected;
113 app.push_output(&process, stream, &line);
114 }
115 AppEvent::StatusUpdate(processes) => {
116 let log_dir = paths::log_dir(&self.session);
118 for p in &processes {
119 if !app.disk_readers.contains_key(&p.name) {
120 app.disk_readers.insert(
121 p.name.clone(),
122 DiskLogReader::new(log_dir.clone(), p.name.clone()),
123 );
124 }
125 }
126 app.update_processes(processes);
127 }
128 AppEvent::OutputStreamClosed => {
129 self.handle_reconnect();
130 }
131 }
132 }
133 }
134
135 if app.stop_all_on_quit {
136 let _ = cli::request(&self.session, &Request::Shutdown, false).await;
137 }
138 }
139
140 async fn handle_key(&self, app: &mut App, key: crossterm::event::KeyEvent) {
141 if app.input_mode == super::app::InputMode::FilterInput {
142 match input::handle_filter_key(key) {
143 input::FilterAction::Char(c) => app.filter_buf.push(c),
144 input::FilterAction::Backspace => {
145 app.filter_buf.pop();
146 }
147 input::FilterAction::Confirm => app.confirm_filter(),
148 input::FilterAction::Cancel => app.cancel_filter(),
149 }
150 } else {
151 let action = input::handle_key(key);
152 match action {
153 input::Action::SelectNext => app.select_next(),
154 input::Action::SelectPrev => app.select_prev(),
155 input::Action::CycleStream => app.cycle_stream_mode(),
156 input::Action::TogglePause => app.toggle_pause(),
157 input::Action::ScrollUp => app.scroll_up(),
158 input::Action::ScrollDown => app.scroll_down(),
159 input::Action::ScrollToTop => app.scroll_to_top(),
160 input::Action::ScrollToBottom => app.scroll_to_bottom(),
161 input::Action::StartFilter => app.start_filter(),
162 input::Action::ClearFilter => app.clear_filter(),
163 input::Action::Quit => app.quit(),
164 input::Action::QuitAndStop => app.quit_and_stop(),
165 input::Action::Stop => {
166 if let Some(name) = app.selected_name() {
167 let _ = cli::request(
168 &self.session,
169 &Request::Stop {
170 target: name.to_string(),
171 },
172 false,
173 )
174 .await;
175 }
176 }
177 input::Action::StopAll => {
178 let _ = cli::request(&self.session, &Request::StopAll, false).await;
179 }
180 input::Action::Restart => {
181 if let Some(name) = app.selected_name() {
182 let _ = cli::request(
183 &self.session,
184 &Request::Restart {
185 target: name.to_string(),
186 },
187 false,
188 )
189 .await;
190 }
191 }
192 input::Action::None => {}
193 }
194 }
195 }
196
197 fn handle_mouse(app: &mut App, mouse: crossterm::event::MouseEvent) {
198 const MOUSE_SCROLL_LINES: usize = 3;
199 match mouse.kind {
200 MouseEventKind::ScrollUp => app.scroll_up_by(MOUSE_SCROLL_LINES),
201 MouseEventKind::ScrollDown => app.scroll_down_by(MOUSE_SCROLL_LINES),
202 MouseEventKind::Down(MouseButton::Left) if mouse.column < 22 => {
203 let row = mouse.row.saturating_sub(1) as usize;
204 if row < app.processes.len() {
205 app.selected = row;
206 }
207 }
208 _ => {}
209 }
210 }
211
212 fn handle_reconnect(&mut self) {
213 let attempt = match self.reconnect_state {
214 ReconnectState::Connected => 0,
215 ReconnectState::Reconnecting { attempt } => attempt,
216 ReconnectState::Failed => return,
217 };
218
219 if attempt >= MAX_RECONNECT_ATTEMPTS {
220 self.reconnect_state = ReconnectState::Failed;
221 return;
222 }
223
224 self.reconnect_state = ReconnectState::Reconnecting {
225 attempt: attempt + 1,
226 };
227
228 let session_str = self.session.clone();
229 let reconnect_tx = self.tx.clone();
230 tokio::spawn(async move {
231 let delay = Duration::from_secs(2u64.saturating_pow(attempt).min(30));
232 tokio::time::sleep(delay).await;
233 output_stream_reader(&session_str, reconnect_tx).await;
234 });
235 }
236}
237
238pub async fn output_stream_reader(session: &str, tx: mpsc::Sender<AppEvent>) {
239 let stream = match cli::connect(session, false).await {
240 Ok(s) => s,
241 Err(_) => {
242 let _ = tx.send(AppEvent::OutputStreamClosed).await;
243 return;
244 }
245 };
246
247 let (reader, mut writer) = stream.into_split();
248
249 let req = Request::Logs {
250 target: None,
251 tail: 0,
252 follow: true,
253 stderr: false,
254 all: true,
255 timeout_secs: None,
256 lines: None,
257 };
258 let mut json = match serde_json::to_string(&req) {
259 Ok(j) => j,
260 Err(_) => return,
261 };
262 json.push('\n');
263 if writer.write_all(json.as_bytes()).await.is_err() {
264 return;
265 }
266 if writer.flush().await.is_err() {
267 return;
268 }
269
270 let mut lines = BufReader::new(reader);
271 loop {
272 let mut line = String::new();
273 match lines.read_line(&mut line).await {
274 Ok(0) | Err(_) => break,
275 Ok(_) => {
276 if let Ok(resp) = serde_json::from_str::<Response>(&line) {
277 match resp {
278 Response::LogLine {
279 process,
280 stream,
281 line,
282 } => {
283 let _ = tx
284 .send(AppEvent::OutputLine {
285 process,
286 stream,
287 line,
288 })
289 .await;
290 }
291 Response::LogEnd => break,
292 _ => {}
293 }
294 }
295 }
296 }
297 }
298
299 let _ = tx.send(AppEvent::OutputStreamClosed).await;
300}
301
302pub async fn status_poller(session: &str, tx: mpsc::Sender<AppEvent>) {
303 let mut ticker = interval(Duration::from_secs(2));
304 loop {
305 ticker.tick().await;
306 if let Ok(Response::Status { processes }) =
307 cli::request(session, &Request::Status, false).await
308 && tx.send(AppEvent::StatusUpdate(processes)).await.is_err()
309 {
310 break;
311 }
312 }
313}
314
315pub async fn key_reader(tx: mpsc::Sender<AppEvent>) {
316 let mut reader = EventStream::new();
317 while let Some(Ok(event)) = reader.next().await {
318 let app_event = match event {
319 Event::Key(key) => AppEvent::Key(key),
320 Event::Mouse(mouse) => AppEvent::Mouse(mouse),
321 _ => continue,
322 };
323 if tx.send(app_event).await.is_err() {
324 break;
325 }
326 }
327}
328
329pub fn init_disk_readers(session: &str, app: &mut App) {
335 let log_dir = paths::log_dir(session);
336
337 let entries = match std::fs::read_dir(&log_dir) {
338 Ok(e) => e,
339 Err(_) => return,
340 };
341
342 let mut seen_processes: std::collections::HashSet<String> = std::collections::HashSet::new();
344 let mut file_entries: Vec<(String, ProtoStream, std::path::PathBuf)> = Vec::new();
345
346 for entry in entries.flatten() {
347 let name = entry.file_name().to_string_lossy().to_string();
348 let (proc_name, stream) = if let Some(p) = name.strip_suffix(".stdout") {
351 (p.to_string(), ProtoStream::Stdout)
352 } else if let Some(p) = name.strip_suffix(".stderr") {
353 (p.to_string(), ProtoStream::Stderr)
354 } else {
355 continue;
356 };
357
358 seen_processes.insert(proc_name.clone());
359 file_entries.push((proc_name, stream, entry.path()));
360 }
361
362 for proc_name in &seen_processes {
364 app.disk_readers.insert(
365 proc_name.clone(),
366 DiskLogReader::new(log_dir.clone(), proc_name.clone()),
367 );
368 }
369
370 for (proc_name, stream, path) in &file_entries {
372 if let Ok(lines) = tail_file(path, 1000) {
373 for line in lines {
374 app.push_output(proc_name, *stream, &line);
375 }
376 }
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use crate::protocol::{ProcessInfo, ProcessState};
384 use crossterm::event::{KeyModifiers, MouseEvent};
385
386 fn make_process(name: &str) -> ProcessInfo {
387 ProcessInfo {
388 name: name.into(),
389 id: format!("p-{name}"),
390 pid: 100,
391 state: ProcessState::Running,
392 exit_code: None,
393 uptime_secs: Some(10),
394 command: "true".into(),
395 port: None,
396 url: None,
397 restart_count: None,
398 max_restarts: None,
399 restart_policy: None,
400 watched: None,
401 }
402 }
403
404 fn left_click(column: u16, row: u16) -> MouseEvent {
405 MouseEvent {
406 kind: MouseEventKind::Down(MouseButton::Left),
407 column,
408 row,
409 modifiers: KeyModifiers::NONE,
410 }
411 }
412
413 #[test]
414 fn left_click_selects_only_process_rows_inside_left_pane() {
415 let mut app = App::new();
416 app.update_processes(vec![
417 make_process("a"),
418 make_process("b"),
419 make_process("c"),
420 ]);
421
422 TuiEventLoop::handle_mouse(&mut app, left_click(21, 3));
423 assert_eq!(app.selected, 2);
424
425 TuiEventLoop::handle_mouse(&mut app, left_click(22, 1));
426 assert_eq!(app.selected, 2, "right pane click must not select");
427
428 TuiEventLoop::handle_mouse(&mut app, left_click(21, 4));
429 assert_eq!(app.selected, 2, "row beyond process list must not select");
430 }
431}