1use crate::protocol::{ProcessInfo, Stream};
2use std::collections::{HashMap, VecDeque};
3
4const MAX_BUFFER_LINES: usize = 10_000;
5
6#[derive(Debug, Clone, Copy, PartialEq)]
7pub enum StreamMode {
8 Stdout,
9 Stderr,
10 Both,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub enum LineSource {
15 Stdout,
16 Stderr,
17}
18
19pub struct OutputBuffer {
22 lines: VecDeque<(LineSource, String)>,
23 max_lines: usize,
24}
25
26impl OutputBuffer {
27 pub fn new(max_lines: usize) -> Self {
28 Self {
29 lines: VecDeque::with_capacity(max_lines),
30 max_lines,
31 }
32 }
33
34 pub fn push(&mut self, source: LineSource, line: String) {
35 if self.lines.len() == self.max_lines {
36 self.lines.pop_front();
37 }
38 self.lines.push_back((source, line));
39 }
40
41 pub fn stdout_lines(&self) -> Vec<&str> {
42 self.lines
43 .iter()
44 .filter(|(src, _)| *src == LineSource::Stdout)
45 .map(|(_, s)| s.as_str())
46 .collect()
47 }
48
49 pub fn stderr_lines(&self) -> Vec<&str> {
50 self.lines
51 .iter()
52 .filter(|(src, _)| *src == LineSource::Stderr)
53 .map(|(_, s)| s.as_str())
54 .collect()
55 }
56
57 pub fn all_lines(&self) -> Vec<(LineSource, &str)> {
58 self.lines
59 .iter()
60 .map(|(src, s)| (*src, s.as_str()))
61 .collect()
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq)]
67pub enum InputMode {
68 Normal,
70 FilterInput,
72}
73
74pub struct App {
75 pub processes: Vec<ProcessInfo>,
76 pub selected: usize,
77 pub buffers: HashMap<String, OutputBuffer>,
78 pub stream_mode: StreamMode,
79 pub paused: bool,
80 pub scroll_offsets: HashMap<String, usize>,
81 pub running: bool,
82 pub stop_all_on_quit: bool,
83 pub input_mode: InputMode,
84 pub filter_buf: String,
86 pub filter: Option<String>,
88 pub visible_height: usize,
90}
91
92impl Default for App {
93 fn default() -> Self {
94 Self::new()
95 }
96}
97
98impl App {
99 pub fn new() -> Self {
100 Self {
101 processes: Vec::new(),
102 selected: 0,
103 buffers: HashMap::new(),
104 stream_mode: StreamMode::Stdout,
105 paused: false,
106 scroll_offsets: HashMap::new(),
107 running: true,
108 stop_all_on_quit: false,
109 input_mode: InputMode::Normal,
110 filter_buf: String::new(),
111 filter: None,
112 visible_height: 20,
113 }
114 }
115
116 pub fn update_processes(&mut self, processes: Vec<ProcessInfo>) {
117 self.processes = processes;
118 if self.selected >= self.processes.len() && !self.processes.is_empty() {
119 self.selected = self.processes.len() - 1;
120 }
121 }
122
123 pub fn selected_name(&self) -> Option<&str> {
124 self.processes.get(self.selected).map(|p| p.name.as_str())
125 }
126
127 pub fn select_next(&mut self) {
128 if !self.processes.is_empty() {
129 self.selected = (self.selected + 1) % self.processes.len();
130 }
131 }
132
133 pub fn select_prev(&mut self) {
134 if !self.processes.is_empty() {
135 self.selected = if self.selected == 0 {
136 self.processes.len() - 1
137 } else {
138 self.selected - 1
139 };
140 }
141 }
142
143 pub fn cycle_stream_mode(&mut self) {
144 self.stream_mode = match self.stream_mode {
145 StreamMode::Stdout => StreamMode::Stderr,
146 StreamMode::Stderr => StreamMode::Both,
147 StreamMode::Both => StreamMode::Stdout,
148 };
149 }
150
151 pub fn toggle_pause(&mut self) {
152 self.paused = !self.paused;
153 if !self.paused {
154 if let Some(name) = self.processes.get(self.selected).map(|p| p.name.clone()) {
156 self.scroll_offsets.remove(&name);
157 }
158 }
159 }
160
161 pub fn scroll_up(&mut self) {
163 if !self.paused {
164 self.paused = true;
165 }
166 if let Some(name) = self.selected_name().map(str::to_string) {
167 let half_page = (self.visible_height / 2).max(1);
168 let offset = self.scroll_offsets.entry(name).or_insert(0);
169 *offset = offset.saturating_add(half_page);
170 }
171 }
172
173 pub fn scroll_down(&mut self) {
175 if let Some(name) = self.selected_name().map(str::to_string) {
176 let half_page = (self.visible_height / 2).max(1);
177 let offset = self.scroll_offsets.entry(name).or_insert(0);
178 *offset = offset.saturating_sub(half_page);
179 if *offset == 0 {
180 self.paused = false;
181 }
182 }
183 }
184
185 pub fn scroll_to_top(&mut self) {
186 if !self.paused {
187 self.paused = true;
188 }
189 if let Some(name) = self.selected_name().map(str::to_string) {
190 let total = self.line_count_for(&name);
191 self.scroll_offsets.insert(name, total);
192 }
193 }
194
195 pub fn scroll_to_bottom(&mut self) {
196 if let Some(name) = self.selected_name().map(str::to_string) {
197 self.scroll_offsets.remove(&name);
198 self.paused = false;
199 }
200 }
201
202 fn line_count_for(&self, name: &str) -> usize {
204 let Some(buf) = self.buffers.get(name) else {
205 return 0;
206 };
207 let lines = match self.stream_mode {
208 StreamMode::Stdout => buf.stdout_lines(),
209 StreamMode::Stderr => buf.stderr_lines(),
210 StreamMode::Both => buf.all_lines().into_iter().map(|(_, l)| l).collect(),
211 };
212 match &self.filter {
213 Some(pat) => lines.iter().filter(|l| l.contains(pat.as_str())).count(),
214 None => lines.len(),
215 }
216 }
217
218 pub fn start_filter(&mut self) {
219 self.input_mode = InputMode::FilterInput;
220 self.filter_buf = self.filter.clone().unwrap_or_default();
221 }
222
223 pub fn confirm_filter(&mut self) {
224 self.input_mode = InputMode::Normal;
225 if self.filter_buf.is_empty() {
226 self.filter = None;
227 } else {
228 self.filter = Some(self.filter_buf.clone());
229 }
230 }
231
232 pub fn cancel_filter(&mut self) {
233 self.input_mode = InputMode::Normal;
234 self.filter_buf.clear();
235 }
236
237 pub fn clear_filter(&mut self) {
238 self.filter = None;
239 self.filter_buf.clear();
240 }
241
242 pub fn push_output(&mut self, process: &str, stream: Stream, line: &str) {
243 let buf = self
244 .buffers
245 .entry(process.to_string())
246 .or_insert_with(|| OutputBuffer::new(MAX_BUFFER_LINES));
247 let source = match stream {
248 Stream::Stdout => LineSource::Stdout,
249 Stream::Stderr => LineSource::Stderr,
250 };
251 buf.push(source, line.to_string());
252 }
253
254 pub fn quit(&mut self) {
255 self.running = false;
256 }
257
258 pub fn quit_and_stop(&mut self) {
259 self.stop_all_on_quit = true;
260 self.running = false;
261 }
262
263 pub fn running_count(&self) -> usize {
264 self.processes
265 .iter()
266 .filter(|p| p.state == crate::protocol::ProcessState::Running)
267 .count()
268 }
269
270 pub fn exited_count(&self) -> usize {
271 self.processes
272 .iter()
273 .filter(|p| p.state == crate::protocol::ProcessState::Exited)
274 .count()
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use crate::protocol::{ProcessInfo, ProcessState, Stream};
282
283 fn make_process(name: &str, state: ProcessState) -> ProcessInfo {
284 let exit_code = if state == ProcessState::Exited {
285 Some(0)
286 } else {
287 None
288 };
289 let uptime_secs = if state == ProcessState::Running {
290 Some(10)
291 } else {
292 None
293 };
294 ProcessInfo {
295 name: name.into(),
296 id: format!("p-{}", name),
297 pid: 100,
298 state,
299 exit_code,
300 uptime_secs,
301 command: "true".into(),
302 port: None,
303 url: None,
304 }
305 }
306
307 #[test]
308 fn test_select_next_wraps() {
309 let mut app = App::new();
310 app.update_processes(vec![
311 make_process("a", ProcessState::Running),
312 make_process("b", ProcessState::Running),
313 make_process("c", ProcessState::Running),
314 ]);
315 app.selected = 2; app.select_next();
317 assert_eq!(app.selected, 0);
318 }
319
320 #[test]
321 fn test_select_prev_wraps() {
322 let mut app = App::new();
323 app.update_processes(vec![
324 make_process("a", ProcessState::Running),
325 make_process("b", ProcessState::Running),
326 make_process("c", ProcessState::Running),
327 ]);
328 app.selected = 0;
329 app.select_prev();
330 assert_eq!(app.selected, 2);
331 }
332
333 #[test]
334 fn test_cycle_stream_mode() {
335 let mut app = App::new();
336 assert_eq!(app.stream_mode, StreamMode::Stdout);
337 app.cycle_stream_mode();
338 assert_eq!(app.stream_mode, StreamMode::Stderr);
339 app.cycle_stream_mode();
340 assert_eq!(app.stream_mode, StreamMode::Both);
341 app.cycle_stream_mode();
342 assert_eq!(app.stream_mode, StreamMode::Stdout);
343 }
344
345 #[test]
346 fn test_toggle_pause() {
347 let mut app = App::new();
348 assert!(!app.paused);
349 app.toggle_pause();
350 assert!(app.paused);
351 app.toggle_pause();
352 assert!(!app.paused);
353 }
354
355 #[test]
356 fn test_push_output() {
357 let mut app = App::new();
358 app.push_output("web", Stream::Stdout, "hello world");
359 let buf = app.buffers.get("web").unwrap();
360 let lines = buf.stdout_lines();
361 assert_eq!(lines.len(), 1);
362 assert_eq!(lines[0], "hello world");
363 }
364
365 #[test]
366 fn test_running_count() {
367 let mut app = App::new();
368 app.update_processes(vec![
369 make_process("a", ProcessState::Running),
370 make_process("b", ProcessState::Exited),
371 make_process("c", ProcessState::Running),
372 ]);
373 assert_eq!(app.running_count(), 2);
374 }
375
376 #[test]
377 fn test_exited_count() {
378 let mut app = App::new();
379 app.update_processes(vec![
380 make_process("a", ProcessState::Running),
381 make_process("b", ProcessState::Exited),
382 make_process("c", ProcessState::Exited),
383 ]);
384 assert_eq!(app.exited_count(), 2);
385 }
386}