diff --git a/src/tui/preview.rs b/src/tui/preview.rs index a023e04f..2c901b39 100644 --- a/src/tui/preview.rs +++ b/src/tui/preview.rs @@ -35,17 +35,35 @@ const VT_SCROLLBACK: usize = 100_000; type PlainChild = Arc>>; fn read_bounded(mut reader: impl Read) -> Vec { + read_bounded_with_updates(&mut reader, |_| {}) +} + +fn read_bounded_with_updates(mut reader: impl Read, mut update: impl FnMut(&[u8])) -> Vec { + const UPDATE_INTERVAL: Duration = Duration::from_millis(16); + let mut output = Vec::with_capacity(PREVIEW_MAX_BYTES); let mut buffer = [0; 8192]; + let mut last_update = None; + let mut published_len = 0; loop { match reader.read(&mut buffer) { Ok(0) | Err(_) => break, Ok(read) => { let retained = PREVIEW_MAX_BYTES.saturating_sub(output.len()).min(read); output.extend_from_slice(&buffer[..retained]); + + let update_due = last_update.is_none_or(|last: Instant| last.elapsed() >= UPDATE_INTERVAL); + if retained > 0 && (update_due || output.len() == PREVIEW_MAX_BYTES) { + update(&output); + published_len = output.len(); + last_update = Some(Instant::now()); + } } } } + if output.len() != published_len { + update(&output); + } output } @@ -570,7 +588,17 @@ impl Preview { self.plain_child = Some(child.clone()); self.thread_handle = Some(std::thread::spawn(move || { - let stdout_reader = std::thread::spawn(move || read_bounded(stdout)); + let streaming_content = content.clone(); + let stdout_reader = std::thread::spawn(move || { + read_bounded_with_updates(stdout, |output| { + let Ok(text) = output.to_vec().into_text() else { + return; + }; + if let Ok(mut content) = streaming_content.write() { + *content = PreviewContent::Text(text); + } + }) + }); let stderr_reader = std::thread::spawn(move || read_bounded(stderr)); let status = loop { diff --git a/src/tui/preview_tests.rs b/src/tui/preview_tests.rs index 2b54de4d..5d6daf67 100644 --- a/src/tui/preview_tests.rs +++ b/src/tui/preview_tests.rs @@ -86,6 +86,42 @@ fn bounded_reader_discards_output_after_limit() { assert_eq!(output.len(), PREVIEW_MAX_BYTES); } +#[cfg(unix)] +#[test] +fn plain_preview_streams_before_command_exits() { + use ratatui::backend::TestBackend; + use std::time::{Duration, Instant}; + + let mut preview = Preview::default(); + preview.pty = None; + let mut tui = + super::super::Tui::new_with_height_and_backend(TestBackend::new(20, 5), super::super::Size::Percent(100)) + .unwrap(); + preview.spawn(&mut tui, "printf streamed; sleep 30").unwrap(); + + let started = Instant::now(); + loop { + let has_streamed_output = preview.content.read().is_ok_and(|content| match &*content { + PreviewContent::Text(text) => text + .lines + .iter() + .any(|line| line.spans.iter().any(|span| span.content.as_ref().contains("streamed"))), + _ => false, + }); + if has_streamed_output { + break; + } + assert!( + started.elapsed() < Duration::from_secs(2), + "preview output did not stream" + ); + std::thread::sleep(Duration::from_millis(10)); + } + + preview.kill(); + preview.thread_handle.take().unwrap().join().unwrap(); +} + #[cfg(unix)] #[test] fn plain_preview_can_be_cancelled() {