main
rs 279 lines 9.17 KB
Raw
1 use std::time::{Duration, Instant};
2
3 use ratatui::widgets::TableState;
4
5 use crate::{
6 daemon::ipc::{self, Request},
7 process::{Process, ProcessStatus},
8 };
9
10 /// Which log stream is currently visible in the log pane.
11 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
12 pub enum LogSource {
13 Stdout,
14 Stderr,
15 }
16
17 /// A transient notification shown in the help / status bar.
18 struct StatusMsg {
19 text: String,
20 is_error: bool,
21 at: Instant,
22 }
23
24 /// Top-level application state passed into every render frame.
25 pub struct App {
26 /// Current snapshot of all managed processes (refreshed every 2 s).
27 pub processes: Vec<Process>,
28
29 /// Ratatui stateful-widget state – tracks the highlighted row index.
30 pub table_state: TableState,
31
32 /// Lines loaded from the selected process's log file (~last 200 lines).
33 pub log_lines: Vec<String>,
34
35 /// Whether the log pane shows stdout or stderr.
36 pub log_source: LogSource,
37
38 /// Set to `true` when the user presses `q` / `Esc` to exit.
39 pub should_quit: bool,
40
41 /// Waiting for the second `d` keypress to confirm deletion.
42 pub confirm_delete: bool,
43
44 /// When the last full refresh from the daemon happened.
45 pub last_refresh: Instant,
46
47 /// Optional transient message displayed in the status bar.
48 status: Option<StatusMsg>,
49 }
50
51 impl App {
52 /// Create a fresh, empty `App`.
53 /// Call [`App::refresh`] right away to populate it with live data.
54 pub fn new() -> Self {
55 Self {
56 processes: Vec::new(),
57 table_state: TableState::default(),
58 log_lines: Vec::new(),
59 log_source: LogSource::Stdout,
60 should_quit: false,
61 confirm_delete: false,
62 last_refresh: Instant::now(),
63 status: None,
64 }
65 }
66
67 // ── Daemon communication ─────────────────────────────────────────────────
68
69 /// Pull a fresh process list from the daemon and reload the log pane.
70 pub fn refresh(&mut self) {
71 match ipc::send(&Request::List) {
72 Ok(resp) if resp.success => {
73 if let Some(procs) = resp.processes {
74 self.processes = procs;
75 self.clamp_selection();
76 }
77 }
78 Ok(resp) => self.set_error(resp.message),
79 Err(e) => self.set_error(format!("Daemon unreachable: {e}")),
80 }
81 self.reload_logs();
82 self.last_refresh = Instant::now();
83 }
84
85 /// Restart the currently selected process.
86 pub fn restart_selected(&mut self) {
87 let Some(id) = self.selected_id_str() else {
88 return;
89 };
90 match ipc::send(&Request::Restart {
91 name_or_id: id.clone(),
92 }) {
93 Ok(resp) if resp.success => self.set_ok(format!("↺ Restarted '{id}'")),
94 Ok(resp) => self.set_error(resp.message),
95 Err(e) => self.set_error(format!("IPC error: {e}")),
96 }
97 self.refresh();
98 }
99
100 /// Toggle the selected process:
101 /// - Running → Stop (will not auto-restart)
102 /// - Stopped/Errored → Restart (re-launch)
103 pub fn toggle_stop(&mut self) {
104 let Some(id) = self.selected_id_str() else {
105 return;
106 };
107 let is_running = self
108 .selected_process()
109 .map(|p| p.status == ProcessStatus::Running)
110 .unwrap_or(false);
111
112 let req = if is_running {
113 Request::Stop {
114 name_or_id: id.clone(),
115 }
116 } else {
117 Request::Restart {
118 name_or_id: id.clone(),
119 }
120 };
121
122 match ipc::send(&req) {
123 Ok(resp) if resp.success => self.set_ok(resp.message),
124 Ok(resp) => self.set_error(resp.message),
125 Err(e) => self.set_error(format!("IPC error: {e}")),
126 }
127 self.refresh();
128 }
129
130 /// Arm or execute a deletion.
131 /// First `d` → sets `confirm_delete = true` (UI shows a warning).
132 /// Second `d` → sends the Delete request to the daemon.
133 pub fn delete_selected(&mut self) {
134 if !self.confirm_delete {
135 self.confirm_delete = true;
136 return;
137 }
138 self.confirm_delete = false;
139 let Some(id) = self.selected_id_str() else {
140 return;
141 };
142 match ipc::send(&Request::Delete {
143 name_or_id: id.clone(),
144 }) {
145 Ok(resp) if resp.success => self.set_ok(format!("✕ Deleted '{id}'")),
146 Ok(resp) => self.set_error(resp.message),
147 Err(e) => self.set_error(format!("IPC error: {e}")),
148 }
149 self.refresh();
150 }
151
152 /// Flip between stdout and stderr in the log pane.
153 pub fn toggle_log_source(&mut self) {
154 self.log_source = match self.log_source {
155 LogSource::Stdout => LogSource::Stderr,
156 LogSource::Stderr => LogSource::Stdout,
157 };
158 self.reload_logs();
159 }
160
161 // ── Navigation ───────────────────────────────────────────────────────────
162
163 pub fn select_next(&mut self) {
164 if self.processes.is_empty() {
165 return;
166 }
167 let next = match self.table_state.selected() {
168 Some(i) if i + 1 < self.processes.len() => i + 1,
169 _ => 0,
170 };
171 self.table_state.select(Some(next));
172 self.reload_logs();
173 }
174
175 pub fn select_prev(&mut self) {
176 if self.processes.is_empty() {
177 return;
178 }
179 let prev = match self.table_state.selected() {
180 Some(0) | None => self.processes.len() - 1,
181 Some(i) => i - 1,
182 };
183 self.table_state.select(Some(prev));
184 self.reload_logs();
185 }
186
187 // ── Accessors ────────────────────────────────────────────────────────────
188
189 /// Return an immutable reference to the currently selected process, if any.
190 pub fn selected_process(&self) -> Option<&Process> {
191 self.table_state
192 .selected()
193 .and_then(|i| self.processes.get(i))
194 }
195
196 /// Return the numeric ID of the selected process as a string (used for IPC).
197 fn selected_id_str(&self) -> Option<String> {
198 self.selected_process().map(|p| p.id.to_string())
199 }
200
201 // ── Log loading ──────────────────────────────────────────────────────────
202
203 /// Read the appropriate log file for the selected process and populate
204 /// `self.log_lines` with its last ~200 lines.
205 pub fn reload_logs(&mut self) {
206 self.log_lines.clear();
207
208 let Some(proc) = self.selected_process() else {
209 return;
210 };
211
212 let path = match self.log_source {
213 LogSource::Stdout => proc.log_out.clone(),
214 LogSource::Stderr => proc.log_err.clone(),
215 };
216
217 if let Ok(content) = std::fs::read_to_string(&path) {
218 // Take the last 200 lines so the buffer stays small.
219 let lines: Vec<String> = content
220 .lines()
221 .rev()
222 .take(200)
223 .map(String::from)
224 .collect::<Vec<_>>()
225 .into_iter()
226 .rev()
227 .collect();
228 self.log_lines = lines;
229 }
230 }
231
232 // ── Selection clamping ───────────────────────────────────────────────────
233
234 /// Ensure the selection index is valid after a process list update.
235 /// Selects index 0 if nothing was selected before.
236 fn clamp_selection(&mut self) {
237 if self.processes.is_empty() {
238 self.table_state.select(None);
239 return;
240 }
241 match self.table_state.selected() {
242 None => self.table_state.select(Some(0)),
243 Some(i) if i >= self.processes.len() => {
244 self.table_state.select(Some(self.processes.len() - 1));
245 }
246 _ => {}
247 }
248 }
249
250 // ── Status messages ──────────────────────────────────────────────────────
251
252 fn set_ok(&mut self, msg: String) {
253 self.status = Some(StatusMsg {
254 text: msg,
255 is_error: false,
256 at: Instant::now(),
257 });
258 }
259
260 fn set_error(&mut self, msg: String) {
261 self.status = Some(StatusMsg {
262 text: msg,
263 is_error: true,
264 at: Instant::now(),
265 });
266 }
267
268 /// Return the current status message if it is still within the 4-second
269 /// display window. Returns `(text, is_error)`.
270 pub fn status_text(&self) -> Option<(&str, bool)> {
271 self.status.as_ref().and_then(|s| {
272 if s.at.elapsed() < Duration::from_secs(4) {
273 Some((s.text.as_str(), s.is_error))
274 } else {
275 None
276 }
277 })
278 }
279 }