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