1use crate::protocol::{ProcessInfo, Stream};
2use crate::tui::disk_log_reader::DiskLogReader;
3use std::collections::{HashMap, VecDeque};
4
5const MAX_BUFFER_LINES: usize = 10_000;
6
7#[derive(Debug, Clone, Copy, PartialEq)]
8pub enum StreamMode {
9 Stdout,
10 Stderr,
11 Both,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub enum LineSource {
16 Stdout,
17 Stderr,
18}
19
20pub struct OutputBuffer {
23 lines: VecDeque<(LineSource, String)>,
24 max_lines: usize,
25 stdout_count: usize,
26 stderr_count: usize,
27}
28
29impl OutputBuffer {
30 pub fn new(max_lines: usize) -> Self {
31 Self {
32 lines: VecDeque::with_capacity(max_lines),
33 max_lines,
34 stdout_count: 0,
35 stderr_count: 0,
36 }
37 }
38
39 pub fn push(&mut self, source: LineSource, line: String) {
40 if self.lines.len() == self.max_lines
41 && let Some((evicted, _)) = self.lines.pop_front()
42 {
43 match evicted {
44 LineSource::Stdout => self.stdout_count -= 1,
45 LineSource::Stderr => self.stderr_count -= 1,
46 }
47 }
48 match source {
49 LineSource::Stdout => self.stdout_count += 1,
50 LineSource::Stderr => self.stderr_count += 1,
51 }
52 self.lines.push_back((source, line));
53 }
54
55 pub fn len(&self) -> usize {
57 self.lines.len()
58 }
59
60 pub fn is_empty(&self) -> bool {
61 self.lines.is_empty()
62 }
63
64 pub fn stdout_count(&self) -> usize {
66 self.stdout_count
67 }
68
69 pub fn stderr_count(&self) -> usize {
71 self.stderr_count
72 }
73
74 pub fn stdout_lines(&self) -> impl Iterator<Item = &str> {
75 self.lines
76 .iter()
77 .filter(|(src, _)| *src == LineSource::Stdout)
78 .map(|(_, s)| s.as_str())
79 }
80
81 pub fn stderr_lines(&self) -> impl Iterator<Item = &str> {
82 self.lines
83 .iter()
84 .filter(|(src, _)| *src == LineSource::Stderr)
85 .map(|(_, s)| s.as_str())
86 }
87
88 pub fn all_lines(&self) -> impl Iterator<Item = (LineSource, &str)> {
89 self.lines.iter().map(|(src, s)| (*src, s.as_str()))
90 }
91}
92
93pub struct FilteredIndex {
95 pub filter: String,
96 pub stream_mode: StreamMode,
97 pub matching_lines: Vec<usize>,
99 pub scanned_up_to: usize,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq)]
105pub enum InputMode {
106 Normal,
108 FilterInput,
110}
111
112pub struct App {
113 pub processes: Vec<ProcessInfo>,
114 pub selected: usize,
115 pub buffers: HashMap<String, OutputBuffer>,
116 pub stream_mode: StreamMode,
117 pub paused: bool,
118 pub scroll_offsets: HashMap<String, usize>,
119 pub running: bool,
120 pub stop_all_on_quit: bool,
121 pub input_mode: InputMode,
122 pub filter_buf: String,
124 pub filter: Option<String>,
126 pub visible_height: usize,
128 pub disk_readers: HashMap<String, DiskLogReader>,
130 pub filtered_indices: HashMap<String, FilteredIndex>,
132}
133
134impl Default for App {
135 fn default() -> Self {
136 Self::new()
137 }
138}
139
140impl App {
141 pub fn new() -> Self {
142 Self {
143 processes: Vec::new(),
144 selected: 0,
145 buffers: HashMap::new(),
146 stream_mode: StreamMode::Stdout,
147 paused: false,
148 scroll_offsets: HashMap::new(),
149 running: true,
150 stop_all_on_quit: false,
151 input_mode: InputMode::Normal,
152 filter_buf: String::new(),
153 filter: None,
154 visible_height: 20,
155 disk_readers: HashMap::new(),
156 filtered_indices: HashMap::new(),
157 }
158 }
159
160 pub fn update_processes(&mut self, processes: Vec<ProcessInfo>) {
161 self.processes = processes;
162 if self.selected >= self.processes.len() && !self.processes.is_empty() {
163 self.selected = self.processes.len() - 1;
164 }
165 }
166
167 pub fn selected_name(&self) -> Option<&str> {
168 self.processes.get(self.selected).map(|p| p.name.as_str())
169 }
170
171 pub fn select_next(&mut self) {
172 if !self.processes.is_empty() {
173 self.selected = (self.selected + 1) % self.processes.len();
174 }
175 }
176
177 pub fn select_prev(&mut self) {
178 if !self.processes.is_empty() {
179 self.selected = if self.selected == 0 {
180 self.processes.len() - 1
181 } else {
182 self.selected - 1
183 };
184 }
185 }
186
187 pub fn cycle_stream_mode(&mut self) {
188 self.stream_mode = match self.stream_mode {
189 StreamMode::Stdout => StreamMode::Stderr,
190 StreamMode::Stderr => StreamMode::Both,
191 StreamMode::Both => StreamMode::Stdout,
192 };
193 if self.filter.is_some() {
195 let names: Vec<String> = self.disk_readers.keys().cloned().collect();
196 for name in names {
197 self.build_filtered_index(&name);
198 }
199 }
200 }
201
202 pub fn toggle_pause(&mut self) {
203 self.paused = !self.paused;
204 if !self.paused {
205 if let Some(name) = self.processes.get(self.selected).map(|p| p.name.clone()) {
207 self.scroll_offsets.remove(&name);
208 }
209 }
210 }
211
212 pub fn scroll_up_by(&mut self, lines: usize) {
215 if !self.paused {
216 self.paused = true;
217 }
218 if let Some(name) = self.selected_name().map(str::to_string) {
219 let max_offset = self
220 .line_count_for(&name)
221 .saturating_sub(self.visible_height);
222 let offset = self.scroll_offsets.entry(name).or_insert(0);
223 *offset = offset.saturating_add(lines).min(max_offset);
224 }
225 }
226
227 pub fn scroll_up(&mut self) {
229 let half_page = (self.visible_height / 2).max(1);
230 self.scroll_up_by(half_page);
231 }
232
233 pub fn scroll_down_by(&mut self, lines: usize) {
235 if let Some(name) = self.selected_name().map(str::to_string) {
236 let offset = self.scroll_offsets.entry(name).or_insert(0);
237 *offset = offset.saturating_sub(lines);
238 if *offset == 0 {
239 self.paused = false;
240 }
241 }
242 }
243
244 pub fn scroll_down(&mut self) {
246 let half_page = (self.visible_height / 2).max(1);
247 self.scroll_down_by(half_page);
248 }
249
250 pub fn scroll_to_top(&mut self) {
251 if !self.paused {
252 self.paused = true;
253 }
254 if let Some(name) = self.selected_name().map(str::to_string) {
255 let total = self.line_count_for(&name);
256 self.scroll_offsets.insert(name, total);
257 }
258 }
259
260 pub fn scroll_to_bottom(&mut self) {
261 if let Some(name) = self.selected_name().map(str::to_string) {
262 self.scroll_offsets.remove(&name);
263 self.paused = false;
264 }
265 }
266
267 fn line_count_for(&mut self, name: &str) -> usize {
271 if self.filter.is_some() {
272 return self
273 .filtered_indices
274 .get(name)
275 .map_or(0, |fi| fi.matching_lines.len());
276 }
277 self.total_line_count(name)
278 }
279
280 fn total_line_count(&mut self, name: &str) -> usize {
284 let hot = self.hot_line_count(name, None);
285 let disk = self.disk_line_count(name);
286 disk.max(hot)
287 }
288
289 fn disk_line_count(&mut self, name: &str) -> usize {
291 let mode = self.stream_mode;
292 self.disk_readers.get_mut(name).map_or(0, |r| match mode {
293 StreamMode::Stdout => r.line_count(LineSource::Stdout),
294 StreamMode::Stderr => r.line_count(LineSource::Stderr),
295 StreamMode::Both => r.line_count_both(),
296 })
297 }
298
299 fn hot_line_count(&self, name: &str, filter: Option<&str>) -> usize {
302 let Some(buf) = self.buffers.get(name) else {
303 return 0;
304 };
305 if let Some(pat) = filter {
306 let matches = |line: &str| line.contains(pat);
307 return match self.stream_mode {
308 StreamMode::Stdout => buf.stdout_lines().filter(|l| matches(l)).count(),
309 StreamMode::Stderr => buf.stderr_lines().filter(|l| matches(l)).count(),
310 StreamMode::Both => buf.all_lines().filter(|(_, l)| matches(l)).count(),
311 };
312 }
313 match self.stream_mode {
314 StreamMode::Stdout => buf.stdout_count(),
315 StreamMode::Stderr => buf.stderr_count(),
316 StreamMode::Both => buf.len(),
317 }
318 }
319
320 pub fn visible_lines(
325 &mut self,
326 name: &str,
327 visible_height: usize,
328 ) -> Option<Vec<(LineSource, String)>> {
329 if self.filter.is_some() {
330 return Some(self.filtered_visible_lines(name, visible_height));
331 }
332
333 let total = self.total_line_count(name);
334 let scroll_offset = if self.paused {
335 self.scroll_offsets.get(name).copied().unwrap_or(0)
336 } else {
337 0
338 };
339
340 let window_end = total.saturating_sub(scroll_offset);
341 let window_start = window_end.saturating_sub(visible_height);
342 let count = window_end - window_start;
343
344 if count == 0 {
345 return Some(Vec::new());
346 }
347
348 let hot_len = self.hot_line_count(name, None);
349 let disk_count = self.disk_line_count(name);
350 let disk_boundary = disk_count.saturating_sub(hot_len);
352
353 if window_start >= disk_boundary {
354 let hot_start = window_start - disk_boundary;
356 Some(self.hot_buffer_range(name, hot_start, count))
357 } else if window_end <= disk_boundary {
358 Some(self.disk_read_range(name, window_start, count))
360 } else {
361 let disk_part = disk_boundary - window_start;
363 let hot_part = window_end - disk_boundary;
364 let mut lines = self.disk_read_range(name, window_start, disk_part);
365 lines.extend(self.hot_buffer_range(name, 0, hot_part));
366 Some(lines)
367 }
368 }
369
370 fn hot_buffer_range(
372 &self,
373 name: &str,
374 start: usize,
375 count: usize,
376 ) -> Vec<(LineSource, String)> {
377 let Some(buf) = self.buffers.get(name) else {
378 return Vec::new();
379 };
380 match self.stream_mode {
381 StreamMode::Stdout => buf
382 .stdout_lines()
383 .skip(start)
384 .take(count)
385 .map(|l| (LineSource::Stdout, l.to_string()))
386 .collect(),
387 StreamMode::Stderr => buf
388 .stderr_lines()
389 .skip(start)
390 .take(count)
391 .map(|l| (LineSource::Stderr, l.to_string()))
392 .collect(),
393 StreamMode::Both => buf
394 .all_lines()
395 .skip(start)
396 .take(count)
397 .map(|(src, l)| (src, l.to_string()))
398 .collect(),
399 }
400 }
401
402 fn disk_read_range(
404 &mut self,
405 name: &str,
406 start: usize,
407 count: usize,
408 ) -> Vec<(LineSource, String)> {
409 let Some(reader) = self.disk_readers.get_mut(name) else {
410 return Vec::new();
411 };
412 match self.stream_mode {
413 StreamMode::Stdout => reader
414 .read_lines(LineSource::Stdout, start, count)
415 .unwrap_or_default()
416 .into_iter()
417 .map(|l| (LineSource::Stdout, l))
418 .collect(),
419 StreamMode::Stderr => reader
420 .read_lines(LineSource::Stderr, start, count)
421 .unwrap_or_default()
422 .into_iter()
423 .map(|l| (LineSource::Stderr, l))
424 .collect(),
425 StreamMode::Both => reader.read_interleaved(start, count).unwrap_or_default(),
426 }
427 }
428
429 fn filtered_visible_lines(
431 &mut self,
432 name: &str,
433 visible_height: usize,
434 ) -> Vec<(LineSource, String)> {
435 let total = self
436 .filtered_indices
437 .get(name)
438 .map_or(0, |fi| fi.matching_lines.len());
439 if total == 0 {
440 return Vec::new();
441 }
442
443 let scroll_offset = if self.paused {
444 self.scroll_offsets.get(name).copied().unwrap_or(0)
445 } else {
446 0
447 };
448
449 let window_end = total.saturating_sub(scroll_offset);
450 let window_start = window_end.saturating_sub(visible_height);
451 let count = window_end - window_start;
452 if count == 0 {
453 return Vec::new();
454 }
455
456 let line_numbers: Vec<usize> = self
458 .filtered_indices
459 .get(name)
460 .map(|fi| fi.matching_lines[window_start..window_end].to_vec())
461 .unwrap_or_default();
462
463 let mode = self.stream_mode;
464
465 if let Some(reader) = self.disk_readers.get_mut(name) {
467 reader.read_scattered_lines(mode, &line_numbers)
468 } else {
469 Vec::new()
470 }
471 }
472
473 fn build_filtered_index(&mut self, name: &str) {
475 let Some(filter) = self.filter.clone() else {
476 return;
477 };
478 let mode = self.stream_mode;
479
480 let matching_lines = if let Some(reader) = self.disk_readers.get_mut(name) {
481 reader.scan_matching_lines(&filter, mode)
482 } else {
483 Vec::new()
484 };
485
486 let scanned_up_to = match mode {
487 StreamMode::Stdout => self
488 .disk_readers
489 .get_mut(name)
490 .map_or(0, |r| r.line_count(LineSource::Stdout)),
491 StreamMode::Stderr => self
492 .disk_readers
493 .get_mut(name)
494 .map_or(0, |r| r.line_count(LineSource::Stderr)),
495 StreamMode::Both => self
496 .disk_readers
497 .get_mut(name)
498 .map_or(0, DiskLogReader::line_count_both),
499 };
500
501 self.filtered_indices.insert(
502 name.to_string(),
503 FilteredIndex {
504 filter,
505 stream_mode: mode,
506 matching_lines,
507 scanned_up_to,
508 },
509 );
510 }
511
512 pub fn start_filter(&mut self) {
513 self.input_mode = InputMode::FilterInput;
514 self.filter_buf = self.filter.clone().unwrap_or_default();
515 }
516
517 pub fn confirm_filter(&mut self) {
518 self.input_mode = InputMode::Normal;
519 if self.filter_buf.is_empty() {
520 self.filter = None;
521 self.filtered_indices.clear();
522 } else {
523 self.filter = Some(self.filter_buf.clone());
524 let names: Vec<String> = self.disk_readers.keys().cloned().collect();
526 for name in names {
527 self.build_filtered_index(&name);
528 }
529 }
530 }
531
532 pub fn cancel_filter(&mut self) {
533 self.input_mode = InputMode::Normal;
534 self.filter_buf.clear();
535 }
536
537 pub fn clear_filter(&mut self) {
538 self.filter = None;
539 self.filter_buf.clear();
540 self.filtered_indices.clear();
541 }
542
543 pub fn push_output(&mut self, process: &str, stream: Stream, line: &str) {
544 let buf = self
545 .buffers
546 .entry(process.to_string())
547 .or_insert_with(|| OutputBuffer::new(MAX_BUFFER_LINES));
548 let source = match stream {
549 Stream::Stdout => LineSource::Stdout,
550 Stream::Stderr => LineSource::Stderr,
551 };
552 buf.push(source, line.to_string());
553
554 if self.filter.is_some() {
556 self.update_filtered_index(process);
557 }
558 }
559
560 fn update_filtered_index(&mut self, process: &str) {
562 let Some(fi) = self.filtered_indices.get(process) else {
563 return;
564 };
565 let filter = fi.filter.clone();
566 let mode = fi.stream_mode;
567 let scanned_up_to = fi.scanned_up_to;
568
569 let reader = match self.disk_readers.get_mut(process) {
570 Some(r) => r,
571 None => return,
572 };
573
574 let current_total = match mode {
575 StreamMode::Stdout => reader.line_count(LineSource::Stdout),
576 StreamMode::Stderr => reader.line_count(LineSource::Stderr),
577 StreamMode::Both => reader.line_count_both(),
578 };
579
580 if current_total <= scanned_up_to {
581 return;
582 }
583
584 let new_matches = reader.scan_matching_lines_from(&filter, mode, scanned_up_to);
585
586 let fi = self.filtered_indices.get_mut(process).unwrap();
587 fi.matching_lines.extend(new_matches);
588 fi.scanned_up_to = current_total;
589 }
590
591 pub fn quit(&mut self) {
592 self.running = false;
593 }
594
595 pub fn quit_and_stop(&mut self) {
596 self.stop_all_on_quit = true;
597 self.running = false;
598 }
599
600 pub fn running_count(&self) -> usize {
601 self.processes
602 .iter()
603 .filter(|p| p.state == crate::protocol::ProcessState::Running)
604 .count()
605 }
606
607 pub fn exited_count(&self) -> usize {
608 self.processes
609 .iter()
610 .filter(|p| p.state == crate::protocol::ProcessState::Exited)
611 .count()
612 }
613
614 pub fn failed_count(&self) -> usize {
615 self.processes
616 .iter()
617 .filter(|p| p.state == crate::protocol::ProcessState::Failed)
618 .count()
619 }
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625 use crate::protocol::{ProcessInfo, ProcessState, Stream};
626
627 fn make_process(name: &str, state: ProcessState) -> ProcessInfo {
628 let exit_code = if state == ProcessState::Exited {
629 Some(0)
630 } else {
631 None
632 };
633 let uptime_secs = if state == ProcessState::Running {
634 Some(10)
635 } else {
636 None
637 };
638 ProcessInfo {
639 name: name.into(),
640 id: format!("p-{}", name),
641 pid: 100,
642 state,
643 exit_code,
644 uptime_secs,
645 command: "true".into(),
646 port: None,
647 url: None,
648 restart_count: None,
649 max_restarts: None,
650 restart_policy: None,
651 watched: None,
652 }
653 }
654
655 #[test]
656 fn test_select_next_wraps() {
657 let mut app = App::new();
658 app.update_processes(vec![
659 make_process("a", ProcessState::Running),
660 make_process("b", ProcessState::Running),
661 make_process("c", ProcessState::Running),
662 ]);
663 app.selected = 2; app.select_next();
665 assert_eq!(app.selected, 0);
666 }
667
668 #[test]
669 fn test_select_prev_wraps() {
670 let mut app = App::new();
671 app.update_processes(vec![
672 make_process("a", ProcessState::Running),
673 make_process("b", ProcessState::Running),
674 make_process("c", ProcessState::Running),
675 ]);
676 app.selected = 0;
677 app.select_prev();
678 assert_eq!(app.selected, 2);
679 }
680
681 #[test]
682 fn test_cycle_stream_mode() {
683 let mut app = App::new();
684 assert_eq!(app.stream_mode, StreamMode::Stdout);
685 app.cycle_stream_mode();
686 assert_eq!(app.stream_mode, StreamMode::Stderr);
687 app.cycle_stream_mode();
688 assert_eq!(app.stream_mode, StreamMode::Both);
689 app.cycle_stream_mode();
690 assert_eq!(app.stream_mode, StreamMode::Stdout);
691 }
692
693 #[test]
694 fn test_toggle_pause() {
695 let mut app = App::new();
696 assert!(!app.paused);
697 app.toggle_pause();
698 assert!(app.paused);
699 app.toggle_pause();
700 assert!(!app.paused);
701 }
702
703 #[test]
704 fn test_push_output() {
705 let mut app = App::new();
706 app.push_output("web", Stream::Stdout, "hello world");
707 let buf = app.buffers.get("web").unwrap();
708 assert_eq!(buf.stdout_lines().count(), 1);
709 assert_eq!(buf.stdout_lines().next().unwrap(), "hello world");
710 }
711
712 #[test]
713 fn test_running_count() {
714 let mut app = App::new();
715 app.update_processes(vec![
716 make_process("a", ProcessState::Running),
717 make_process("b", ProcessState::Exited),
718 make_process("c", ProcessState::Running),
719 ]);
720 assert_eq!(app.running_count(), 2);
721 }
722
723 #[test]
724 fn test_exited_count() {
725 let mut app = App::new();
726 app.update_processes(vec![
727 make_process("a", ProcessState::Running),
728 make_process("b", ProcessState::Exited),
729 make_process("c", ProcessState::Exited),
730 ]);
731 assert_eq!(app.exited_count(), 2);
732 }
733
734 #[test]
735 fn test_output_buffer_counters() {
736 let mut buf = OutputBuffer::new(5);
737 assert_eq!(buf.len(), 0);
738 assert_eq!(buf.stdout_count(), 0);
739 assert_eq!(buf.stderr_count(), 0);
740 assert!(buf.is_empty());
741
742 buf.push(LineSource::Stdout, "a".into());
743 buf.push(LineSource::Stderr, "b".into());
744 buf.push(LineSource::Stdout, "c".into());
745 assert_eq!(buf.len(), 3);
746 assert_eq!(buf.stdout_count(), 2);
747 assert_eq!(buf.stderr_count(), 1);
748
749 buf.push(LineSource::Stdout, "d".into());
751 buf.push(LineSource::Stderr, "e".into());
752 assert_eq!(buf.len(), 5);
753 assert_eq!(buf.stdout_count(), 3);
754 assert_eq!(buf.stderr_count(), 2);
755
756 buf.push(LineSource::Stderr, "f".into());
758 assert_eq!(buf.len(), 5);
759 assert_eq!(buf.stdout_count(), 2);
760 assert_eq!(buf.stderr_count(), 3);
761 }
762
763 #[test]
764 fn test_visible_lines_hot_buffer_only() {
765 let mut app = App::new();
766 for i in 0..20 {
768 app.push_output("web", Stream::Stdout, &format!("line {}", i));
769 }
770 app.update_processes(vec![make_process("web", ProcessState::Running)]);
771 app.visible_height = 10;
772
773 let lines = app.visible_lines("web", 10).unwrap();
775 assert_eq!(lines.len(), 10);
776 assert_eq!(lines[0].1, "line 10");
777 assert_eq!(lines[9].1, "line 19");
778 }
779
780 #[test]
781 fn test_visible_lines_paused_scrolled() {
782 let mut app = App::new();
783 for i in 0..50 {
784 app.push_output("web", Stream::Stdout, &format!("line {}", i));
785 }
786 app.update_processes(vec![make_process("web", ProcessState::Running)]);
787 app.visible_height = 10;
788 app.paused = true;
789 app.scroll_offsets.insert("web".into(), 20);
790
791 let lines = app.visible_lines("web", 10).unwrap();
792 assert_eq!(lines.len(), 10);
793 assert_eq!(lines[0].1, "line 20");
795 assert_eq!(lines[9].1, "line 29");
796 }
797
798 #[test]
799 fn test_visible_lines_with_disk_reader() {
800 use crate::daemon::log_index::{IndexRecord, IndexWriter, idx_path_for};
801 use crate::tui::disk_log_reader::DiskLogReader;
802
803 let dir = tempfile::tempdir().unwrap();
804
805 let log_path = dir.path().join("web.stdout");
807 let idx_path = idx_path_for(&log_path);
808 let mut log_content = String::new();
809 let mut writer = IndexWriter::create(&idx_path, 0).unwrap();
810 let mut offset = 0u64;
811 for i in 0..100 {
812 let line = format!("disk line {}", i);
813 writer
814 .append(IndexRecord {
815 byte_offset: offset,
816 seq: i,
817 })
818 .unwrap();
819 log_content.push_str(&line);
820 log_content.push('\n');
821 offset += line.len() as u64 + 1;
822 }
823 writer.flush().unwrap();
824 std::fs::write(&log_path, log_content).unwrap();
825
826 let mut app = App::new();
827 app.disk_readers.insert(
828 "web".into(),
829 DiskLogReader::new(dir.path().to_path_buf(), "web".into()),
830 );
831
832 for i in 90..100 {
834 app.push_output("web", Stream::Stdout, &format!("disk line {}", i));
835 }
836
837 app.update_processes(vec![make_process("web", ProcessState::Running)]);
838 app.visible_height = 10;
839
840 assert_eq!(app.total_line_count("web"), 100);
842
843 let lines = app.visible_lines("web", 10).unwrap();
845 assert_eq!(lines.len(), 10);
846 assert_eq!(lines[0].1, "disk line 90");
847 assert_eq!(lines[9].1, "disk line 99");
848
849 app.paused = true;
851 app.scroll_offsets.insert("web".into(), 90);
852 let lines = app.visible_lines("web", 10).unwrap();
853 assert_eq!(lines.len(), 10);
854 assert_eq!(lines[0].1, "disk line 0");
855 assert_eq!(lines[9].1, "disk line 9");
856
857 app.scroll_offsets.insert("web".into(), 5);
862 let lines = app.visible_lines("web", 10).unwrap();
863 assert_eq!(lines.len(), 10);
864 assert_eq!(lines[0].1, "disk line 85");
865 assert_eq!(lines[4].1, "disk line 89");
866 assert_eq!(lines[5].1, "disk line 90");
867 assert_eq!(lines[9].1, "disk line 94");
868 }
869
870 #[test]
871 fn test_visible_lines_with_filter_uses_filtered_index() {
872 let mut app = App::new();
873 app.push_output("web", Stream::Stdout, "hello");
874 app.filter = Some("hello".into());
875
876 let lines = app.visible_lines("web", 10).unwrap();
878 assert!(lines.is_empty());
879 }
880
881 #[test]
882 fn test_hot_line_count_with_and_without_filter() {
883 let mut app = App::new();
884 app.push_output("web", Stream::Stdout, "hello world");
885 app.push_output("web", Stream::Stdout, "goodbye world");
886 app.push_output("web", Stream::Stdout, "hello again");
887
888 assert_eq!(app.hot_line_count("web", None), 3);
890
891 assert_eq!(app.hot_line_count("web", Some("hello")), 2);
893 assert_eq!(app.hot_line_count("web", Some("xyz")), 0);
894 }
895}