use crate::config::{AddressArguments, Platform}; use crate::{ builder::{BuildResult, UpdateStage}, builder::{Stage, TargetPlatform, UpdateBuildProgress}, dioxus_crate::DioxusCrate, serve::next_or_pending, serve::Serve, serve::{Builder, Server, Watcher}, tracer::CLILogControl, TraceMsg, TraceSrc, }; use crossterm::{ cursor::{Hide, Show}, event::{Event, EventStream, KeyCode, KeyEventKind, KeyModifiers}, terminal::{ disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, }, tty::IsTty, ExecutableCommand, }; use dioxus_devtools::ClientMsg; use futures_util::{future::select_all, Future, FutureExt, StreamExt}; use ratatui::{prelude::*, TerminalOptions, Viewport}; use std::{ cell::RefCell, collections::{HashMap, HashSet}, io::{self, stdout}, rc::Rc, sync::atomic::Ordering, time::{Duration, Instant}, }; use tokio::{ io::{AsyncBufReadExt, BufReader, Lines}, process::{ChildStderr, ChildStdout}, }; use tracing::Level; use super::render; // How many lines should be scroll on each mouse scroll or arrow key input. const SCROLL_SPEED: u16 = 2; // Speed added to `SCROLL_SPEED` when the modifier key is held during scroll. const SCROLL_MODIFIER: u16 = 4; // Scroll modifier key. const SCROLL_MODIFIER_KEY: KeyModifiers = KeyModifiers::SHIFT; #[derive(Default)] pub struct BuildProgress { pub(crate) current_builds: HashMap, } impl BuildProgress { pub fn progress(&self) -> f64 { self.current_builds .values() .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) .map(|build| match build.stage { Stage::Initializing => 0.0, Stage::InstallingWasmTooling => 0.0, Stage::Compiling => build.progress, Stage::OptimizingWasm | Stage::OptimizingAssets | Stage::Finished => 1.0, }) .unwrap_or_default() } } pub struct Output { term: Rc>>, log_control: CLILogControl, // optional since when there's no tty there's no eventstream to read from - just stdin events: Option, pub(crate) build_progress: BuildProgress, running_apps: HashMap, // A list of all messages from build, dev, app, and more. messages: Vec, num_lines_wrapping: u16, scroll_position: u16, console_width: u16, console_height: u16, more_modal_open: bool, anim_start: Instant, interactive: bool, _is_cli_release: bool, platform: Platform, addr: AddressArguments, // Filters show_filter_menu: bool, filters: Vec<(String, bool)>, selected_filter_index: usize, filter_search_mode: bool, filter_search_input: Option, _rustc_version: String, _rustc_nightly: bool, _dx_version: String, } type TerminalBackend = Terminal>; impl Output { pub fn start(cfg: &Serve, log_control: CLILogControl) -> io::Result { let interactive = std::io::stdout().is_tty() && cfg.interactive.unwrap_or(true); let mut events = None; if interactive { log_control.output_enabled.store(true, Ordering::SeqCst); enable_raw_mode()?; stdout().execute(EnterAlternateScreen)?.execute(Hide)?; // workaround for ci where the terminal is not fully initialized // this stupid bug // https://github.com/crossterm-rs/crossterm/issues/659 events = Some(EventStream::new()); }; // set the panic hook to fix the terminal set_fix_term_hook(); // Fix the vscode scrollback issue fix_xtermjs_scrollback(); let term: Option = Terminal::with_options( CrosstermBackend::new(stdout()), TerminalOptions { viewport: Viewport::Fullscreen, }, ) .ok(); // todo: re-enable rustc version // let rustc_version = rustc_version().await; // let rustc_nightly = rustc_version.contains("nightly") || cfg.target_args.nightly; let _rustc_version = String::from("1.0.0"); let _rustc_nightly = false; let mut dx_version = String::new(); dx_version.push_str(env!("CARGO_PKG_VERSION")); // todo: we want the binstalls / cargo installs to be exempt, but installs from git are not let is_cli_release = crate::dx_build_info::PROFILE == "release"; if !is_cli_release { if let Some(hash) = crate::dx_build_info::GIT_COMMIT_HASH_SHORT { let hash = &hash.trim_start_matches('g')[..4]; dx_version.push('-'); dx_version.push_str(hash); } } let platform = cfg.build_arguments.platform.expect("To be resolved by now"); Ok(Self { term: Rc::new(RefCell::new(term)), log_control, events, _rustc_version, _rustc_nightly, _dx_version: dx_version, interactive, _is_cli_release: is_cli_release, platform, messages: Vec::new(), more_modal_open: false, build_progress: Default::default(), running_apps: HashMap::new(), scroll_position: 0, num_lines_wrapping: 0, console_width: 0, console_height: 0, anim_start: Instant::now(), addr: cfg.server_arguments.address.clone(), // Filter show_filter_menu: false, filters: Vec::new(), selected_filter_index: 0, filter_search_input: None, filter_search_mode: false, }) } /// Add a message from stderr to the logs fn push_stderr(&mut self, platform: TargetPlatform, stderr: String) { self.running_apps .get_mut(&platform) .unwrap() .output .as_mut() .unwrap() .stderr_line .push_str(&stderr); self.messages.push(TraceMsg { source: TraceSrc::App(platform), level: Level::ERROR, content: stderr, }); if self.is_snapped() { self.scroll_to_bottom(); } } /// Add a message from stdout to the logs fn push_stdout(&mut self, platform: TargetPlatform, stdout: String) { self.running_apps .get_mut(&platform) .unwrap() .output .as_mut() .unwrap() .stdout_line .push_str(&stdout); self.messages.push(TraceMsg { source: TraceSrc::App(platform), level: Level::INFO, content: stdout, }); if self.is_snapped() { self.scroll_to_bottom(); } } /// Wait for either the ctrl_c handler or the next event /// /// Why is the ctrl_c handler here? /// /// Also tick animations every few ms pub async fn wait(&mut self) -> io::Result { fn ok_and_some(f: F) -> impl Future where F: Future, E>>, { next_or_pending(async move { f.await.ok().flatten() }) } let user_input = async { let events = self.events.as_mut()?; events.next().await }; let user_input = ok_and_some(user_input.map(|e| e.transpose())); let has_running_apps = !self.running_apps.is_empty(); let next_stdout = self.running_apps.values_mut().map(|app| { let future = async move { let (stdout, stderr) = match &mut app.output { Some(out) => ( ok_and_some(out.stdout.next_line()), ok_and_some(out.stderr.next_line()), ), None => return futures_util::future::pending().await, }; tokio::select! { line = stdout => (app.result.target_platform, Some(line), None), line = stderr => (app.result.target_platform, None, Some(line)), } }; Box::pin(future) }); let next_stdout = async { if has_running_apps { select_all(next_stdout).await.0 } else { futures_util::future::pending().await } }; let tui_log_rx = &mut self.log_control.output_rx; let next_tui_log = next_or_pending(tui_log_rx.next()); tokio::select! { (platform, stdout, stderr) = next_stdout => { if let Some(stdout) = stdout { self.push_stdout(platform, stdout); } if let Some(stderr) = stderr { self.push_stderr(platform, stderr); } }, // Handle internal CLI tracing logs. log = next_tui_log => { self.push_log(log); } event = user_input => { if self.handle_events(event).await? { return Ok(true) } } } Ok(false) } pub fn shutdown(&mut self) -> io::Result<()> { // if we're a tty then we need to disable the raw mode if self.interactive { self.log_control .output_enabled .store(false, Ordering::SeqCst); disable_raw_mode()?; stdout().execute(LeaveAlternateScreen)?.execute(Show)?; self.drain_print_logs(); } Ok(()) } /// Emit the build logs as println! statements such that the terminal has the same output as cargo /// /// This is used when the terminal is shutdown and we want the build logs in the terminal. Old /// versions of the cli would just eat build logs making debugging issues harder than they needed /// to be. fn drain_print_logs(&mut self) { let messages = self.messages.drain(..); for msg in messages { // TODO: Better formatting for different content lengths. if msg.source != TraceSrc::Cargo { println!("[{}] {}: {}", msg.source, msg.level, msg.content); } else { println!("{}", msg.content); } } } /// Handle an input event, returning `true` if the event should cause the program to restart. pub fn handle_input(&mut self, input: Event) -> io::Result { // handle ctrlc if let Event::Key(key) = input { if let KeyCode::Char('c') = key.code { if key.modifiers.contains(KeyModifiers::CONTROL) { return Err(io::Error::new(io::ErrorKind::Interrupted, "Ctrl-C")); } } } // If we're in filter search mode we must capture all key inputs. // This also handles when a filter is submitted. if self.filter_search_mode { if let Event::Key(key) = input { if key.kind != KeyEventKind::Press { return Ok(false); } match key.code { KeyCode::Char(c) => { if let Some(input) = self.filter_search_input.as_mut() { input.push(c); } else { self.filter_search_input = Some(String::from(c)); } } KeyCode::Enter => { if let Some(search) = &self.filter_search_input { self.filters.push((search.to_string(), true)); } self.filter_search_input = None; self.filter_search_mode = false; } KeyCode::Backspace => { if let Some(search) = self.filter_search_input.as_mut() { search.pop(); if search.is_empty() { self.filter_search_input = None; } } } _ => {} } return Ok(false); } } match input { Event::Key(key) if key.code == KeyCode::Up && key.kind == KeyEventKind::Press => { // Select filter list item if filter is showing, otherwise scroll console. if self.show_filter_menu { self.selected_filter_index = self.selected_filter_index.saturating_sub(1); } else { // Scroll up let mut scroll_speed = SCROLL_SPEED; if key.modifiers.contains(SCROLL_MODIFIER_KEY) { scroll_speed += SCROLL_MODIFIER; } self.scroll_position = self.scroll_position.saturating_sub(scroll_speed); } } Event::Key(key) if key.code == KeyCode::Down && key.kind == KeyEventKind::Press => { // Select filter list item if filter is showing, otherwise scroll console. if self.show_filter_menu { let list_len = self.filters.len(); if self.selected_filter_index + 1 < list_len { self.selected_filter_index += 1; } } else { // Scroll down let mut scroll_speed = SCROLL_SPEED; if key.modifiers.contains(SCROLL_MODIFIER_KEY) { scroll_speed += SCROLL_MODIFIER; } self.scroll_position += scroll_speed; } } Event::Key(key) if key.code == KeyCode::Left && key.kind == KeyEventKind::Press => { // Remove selected filter if filter menu is shown. if self.show_filter_menu { let index = self.selected_filter_index; if self.filters.get(index).is_some() { self.filters.remove(index); } } } Event::Key(key) if key.code == KeyCode::Right && key.kind == KeyEventKind::Press => { // Toggle filter if filter menu is shown. if self.show_filter_menu { let index = self.selected_filter_index; self.filters.reverse(); if let Some(item) = self.filters.get_mut(index) { item.1 = !item.1; } self.filters.reverse(); } } Event::Key(key) if key.code == KeyCode::Enter && key.kind == KeyEventKind::Press => { // We only need to listen to the enter key when not in search mode // as there is other logic that handles adding filters and disabling the mode. if self.show_filter_menu { self.filter_search_mode = !self.filter_search_mode; } } Event::Key(key) if key.code == KeyCode::Char('r') && key.kind == KeyEventKind::Press => { // Reload the app return Ok(true); } Event::Key(key) if key.code == KeyCode::Char('o') && key.kind == KeyEventKind::Press => { // Open the running app. open::that(format!("http://{}:{}", self.addr.addr, self.addr.port))?; } Event::Key(key) if key.code == KeyCode::Char('f') && key.kind == KeyEventKind::Press => { // Show filter menu and enable filter selection mode. if self.show_filter_menu { // Reset inputs when filter menu is closed. self.filter_search_mode = false; self.filter_search_input = None; } self.show_filter_menu = !self.show_filter_menu; } Event::Key(key) if key.code == KeyCode::Char('/') && key.kind == KeyEventKind::Press => { // Toggle more modal self.more_modal_open = !self.more_modal_open; } Event::Resize(_width, _height) => { // nothing, it should take care of itself } _ => {} } if self.scroll_position > self.num_lines_wrapping.saturating_sub(self.console_height) { self.scroll_to_bottom(); } Ok(false) } pub fn new_ws_message( &mut self, platform: TargetPlatform, message: axum::extract::ws::Message, ) { // Deccode the message and push it to our logs. if let axum::extract::ws::Message::Text(text) = message { let msg = serde_json::from_str::(text.as_str()); match msg { Ok(ClientMsg::Log { level, messages }) => { let level = match level.as_str() { "trace" => Level::TRACE, "debug" => Level::DEBUG, "info" => Level::INFO, "warn" => Level::WARN, "error" => Level::ERROR, _ => Level::INFO, }; let content = messages.first().unwrap_or(&String::new()).clone(); // We don't care about logging the app's message so we directly push it instead of using tracing. self.push_log(TraceMsg::new(TraceSrc::App(platform), level, content)); } Err(err) => { tracing::error!(dx_src = ?TraceSrc::Dev, "Error parsing message from {}: {}", platform, err); } } } } fn is_snapped(&self) -> bool { true } pub fn scroll_to_bottom(&mut self) { self.scroll_position = self.num_lines_wrapping.saturating_sub(self.console_height); } pub fn push_log(&mut self, message: TraceMsg) { self.messages.push(message); if self.is_snapped() { self.scroll_to_bottom(); } } pub fn new_build_progress(&mut self, platform: TargetPlatform, update: UpdateBuildProgress) { self.build_progress .current_builds .entry(platform) .or_default() .update(update); if self.is_snapped() { self.scroll_to_bottom(); } } pub fn new_ready_app(&mut self, build_engine: &mut Builder, results: Vec) { for result in results { let out = build_engine .children .iter_mut() .find_map(|(platform, child)| { if platform == &result.target_platform { let stdout = child.stdout.take().unwrap(); let stderr = child.stderr.take().unwrap(); Some((stdout, stderr)) } else { None } }); let platform = result.target_platform; let stdout = out.map(|(stdout, stderr)| RunningAppOutput { stdout: BufReader::new(stdout).lines(), stderr: BufReader::new(stderr).lines(), stdout_line: String::new(), stderr_line: String::new(), }); let app = RunningApp { result, output: stdout, }; self.running_apps.insert(platform, app); // Finish the build progress for the platform that just finished building if let Some(build) = self.build_progress.current_builds.get_mut(&platform) { build.stage = Stage::Finished; } } } pub fn render( &mut self, _opts: &Serve, _config: &DioxusCrate, _build_engine: &Builder, _server: &Server, _watcher: &Watcher, ) { // just drain the build logs if !self.interactive { self.drain_print_logs(); return; } // Keep the animation track in terms of 100ms frames - the frame should be a number between 0 and 10 // todo: we want to use this somehow to animate things... let elapsed = self.anim_start.elapsed().as_millis() as f32; let num_frames = elapsed / 100.0; let _frame_step = (num_frames % 10.0) as usize; _ = self .term .clone() .borrow_mut() .as_mut() .unwrap() .draw(|frame| { let mut layout = render::TuiLayout::new(frame.size(), self.show_filter_menu); let (console_width, console_height) = layout.get_console_size(); self.console_width = console_width; self.console_height = console_height; // Render the decor first as some of it (such as backgrounds) may be rendered on top of. layout.render_decor(frame, self.show_filter_menu); // Get only the enabled filters. let mut enabled_filters = self.filters.clone(); enabled_filters.retain(|f| f.1); let enabled_filters = enabled_filters .iter() .map(|f| f.0.clone()) .collect::>(); // Render console, we need the number of wrapping lines for scroll. self.num_lines_wrapping = layout.render_console( frame, self.scroll_position, &self.messages, &enabled_filters, ); if self.show_filter_menu { layout.render_filter_menu( frame, &self.filters, self.selected_filter_index, self.filter_search_mode, self.filter_search_input.as_ref(), ); } layout.render_status_bar( frame, self.platform, &self.build_progress, self.more_modal_open, self.show_filter_menu, &self._dx_version, ); if self.more_modal_open { layout.render_more_modal(frame); } layout.render_current_scroll( self.scroll_position, self.num_lines_wrapping, self.console_height, frame, ); }); } async fn handle_events(&mut self, event: Event) -> io::Result { let mut events = vec![event]; // Collect all the events within the next 10ms in one stream let collect_events = async { loop { let Some(Ok(next)) = self.events.as_mut().unwrap().next().await else { break; }; events.push(next); } }; tokio::select! { _ = collect_events => {}, _ = tokio::time::sleep(Duration::from_millis(10)) => {} } // Debounce events within the same frame let mut handled = HashSet::new(); for event in events { if !handled.contains(&event) { if self.handle_input(event.clone())? { // Restart the running app. return Ok(true); } handled.insert(event); } } Ok(false) } } #[derive(Default, Debug, PartialEq)] pub struct ActiveBuild { pub stage: Stage, pub progress: f64, pub failed: Option, } impl ActiveBuild { fn update(&mut self, update: UpdateBuildProgress) { match update.update { UpdateStage::Start => { // If we are already past the stage, don't roll back, but allow a fresh build to update. if self.stage > update.stage && self.stage < Stage::Finished { return; } self.stage = update.stage; self.progress = 0.0; self.failed = None; } UpdateStage::SetProgress(progress) => { self.progress = progress; } UpdateStage::Failed(failed) => { self.stage = Stage::Finished; self.failed = Some(failed.clone()); } } } pub fn make_spans(&self, area: Rect) -> Vec { let mut spans = Vec::new(); let message = match self.stage { Stage::Initializing => "Initializing...", Stage::InstallingWasmTooling => "Configuring...", Stage::Compiling => "Compiling...", Stage::OptimizingWasm => "Optimizing...", Stage::OptimizingAssets => "Copying Assets...", Stage::Finished => "Build finished! 🎉 ", }; let progress = format!(" {}%", (self.progress * 100.0) as u8); if area.width >= self.max_layout_size() { match self.stage { Stage::Finished => spans.push(Span::from(message).light_yellow()), _ => spans.push(Span::from(message).light_yellow()), } if self.stage != Stage::Finished { spans.push(Span::from(progress).white()); } } else { spans.push(Span::from(progress).white()); } spans } pub fn max_layout_size(&self) -> u16 { let progress_size = 4; let stage_size = self.stage.to_string().len() as u16; let brace_size = 2; progress_size + stage_size + brace_size } } impl PartialOrd for ActiveBuild { fn partial_cmp(&self, other: &Self) -> Option { Some( self.stage .cmp(&other.stage) .then(self.progress.partial_cmp(&other.progress).unwrap()), ) } } fn set_fix_term_hook() { let original_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { _ = disable_raw_mode(); let mut stdout = stdout(); _ = stdout.execute(LeaveAlternateScreen); _ = stdout.execute(Show); original_hook(info); })); } /// clearing and writing a new line fixes the xtermjs scrollback issue fn fix_xtermjs_scrollback() { _ = crossterm::execute!(std::io::stdout(), Clear(ClearType::All)); println!(); } // todo: re-enable #[allow(unused)] async fn rustc_version() -> String { tokio::process::Command::new("rustc") .arg("--version") .output() .await .ok() .map(|o| o.stdout) .and_then(|o| { let out = String::from_utf8(o).unwrap(); out.split_ascii_whitespace().nth(1).map(|v| v.to_string()) }) .unwrap_or_else(|| "".to_string()) } pub struct RunningApp { result: BuildResult, output: Option, } struct RunningAppOutput { stdout: Lines>, stderr: Lines>, stdout_line: String, stderr_line: String, }