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