From 8d688863107883e44e6eec451e41015cea4e82a4 Mon Sep 17 00:00:00 2001 From: Miles Murgaw Date: Fri, 13 Sep 2024 04:34:19 -0400 Subject: [PATCH] CLI Fixes & Tweaks (#2846) Fixes scrolling on vscode using simple fix Adds scroll modifier, scroll at 5 lines per scroll while holding shift key. Adds error handling for manganis failure that still lets the build run. Revises TUI rendering code. Move TUI "info bars" to the bottom of the terminal. Revised logging system with tracing Working [c] clear keybind. This has been removed. Removal of [h] hide keybind text (it does nothing) Some opinionated cleanups and renames to make tui logic easier to understand. Rolling log file & maybe add some more "internal" logging. Closes CLI Rolling Log File #2764 Removes log tabs. Closes CLI: Color-code logs and get rid of tabs #2857 Combines info bars. Working and good text selection. Print launch URL in console. Handle log lines properly and add formatting. Move MessageSource to a more reasonable location. Add some background styling to powerline (info bar) - Tried this and it doesn't look the greatest. Log Filtering Final Cleaning & Changes - I could do this forever Test Linux --------- Co-authored-by: Jonathan Kelley --- Cargo.lock | 1 + packages/cli/Cargo.toml | 1 + packages/cli/src/assets.rs | 30 +- packages/cli/src/builder/cargo.rs | 20 +- packages/cli/src/builder/mod.rs | 39 +- packages/cli/src/builder/prepare_html.rs | 37 +- packages/cli/src/builder/progress.rs | 92 +-- packages/cli/src/builder/web.rs | 16 +- packages/cli/src/cli/config.rs | 14 +- packages/cli/src/cli/create.rs | 9 +- packages/cli/src/cli/serve.rs | 6 +- packages/cli/src/cli/translate.rs | 4 +- packages/cli/src/main.rs | 24 +- packages/cli/src/serve/mod.rs | 24 +- packages/cli/src/serve/output.rs | 772 +++++++++-------------- packages/cli/src/serve/output/render.rs | 487 ++++++++++++++ packages/cli/src/serve/proxy.rs | 3 +- packages/cli/src/serve/server.rs | 14 +- packages/cli/src/serve/watcher.rs | 3 +- packages/cli/src/settings.rs | 7 +- packages/cli/src/tracer.rs | 412 ++++++++++-- packages/web/src/hot_reload.rs | 5 +- 22 files changed, 1302 insertions(+), 718 deletions(-) create mode 100644 packages/cli/src/serve/output/render.rs diff --git a/Cargo.lock b/Cargo.lock index 89d777220..ed9336401 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2520,6 +2520,7 @@ dependencies = [ "proc-macro2", "ratatui", "rayon", + "regex", "reqwest", "rsx-rosetta", "rustls 0.23.12", diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml index a992a0b8e..eb7caeb84 100644 --- a/packages/cli/Cargo.toml +++ b/packages/cli/Cargo.toml @@ -45,6 +45,7 @@ console = "0.15.8" ctrlc = "3.2.3" futures-channel = { workspace = true } krates = { version = "0.17.0" } +regex = "1.10.6" axum = { workspace = true, features = ["ws"] } axum-server = { workspace = true, features = ["tls-rustls"] } diff --git a/packages/cli/src/assets.rs b/packages/cli/src/assets.rs index d08d9eb6f..08b8e55c3 100644 --- a/packages/cli/src/assets.rs +++ b/packages/cli/src/assets.rs @@ -1,7 +1,6 @@ -use crate::builder::{ - BuildMessage, BuildRequest, MessageSource, MessageType, Stage, UpdateBuildProgress, UpdateStage, -}; +use crate::builder::{BuildRequest, Stage, UpdateBuildProgress, UpdateStage}; use crate::Result; +use crate::TraceSrc; use anyhow::Context; use brotli::enc::BrotliEncoderParams; use futures_channel::mpsc::UnboundedSender; @@ -13,19 +12,18 @@ use std::sync::atomic::AtomicUsize; use std::sync::Arc; use std::{ffi::OsString, path::PathBuf}; use std::{fs::File, io::Write}; -use tracing::Level; use walkdir::WalkDir; /// The temp file name for passing manganis json from linker to current exec. pub const MG_JSON_OUT: &str = "mg-out"; -pub fn asset_manifest(build: &BuildRequest) -> AssetManifest { +pub fn asset_manifest(build: &BuildRequest) -> Option { let file_path = build.target_out_dir().join(MG_JSON_OUT); - let read = fs::read_to_string(&file_path).unwrap(); + let read = fs::read_to_string(&file_path).ok()?; _ = fs::remove_file(file_path); let json: Vec = serde_json::from_str(&read).unwrap(); - AssetManifest::load(json) + Some(AssetManifest::load(json)) } /// Create a head file that contains all of the imports for assets that the user project uses @@ -58,17 +56,7 @@ pub(crate) fn process_assets( match process_file(file_asset, &static_asset_output_dir) { Ok(_) => { // Update the progress - _ = progress.start_send(UpdateBuildProgress { - stage: Stage::OptimizingAssets, - update: UpdateStage::AddMessage(BuildMessage { - level: Level::INFO, - message: MessageType::Text(format!( - "Optimized static asset {}", - file_asset - )), - source: MessageSource::Build, - }), - }); + tracing::info!(dx_src = ?TraceSrc::Build, "Optimized static asset {file_asset}"); let assets_finished = assets_finished.fetch_add(1, std::sync::atomic::Ordering::SeqCst); _ = progress.start_send(UpdateBuildProgress { @@ -79,7 +67,7 @@ pub(crate) fn process_assets( }); } Err(err) => { - tracing::error!("Failed to copy static asset: {}", err); + tracing::error!(dx_src = ?TraceSrc::Build, "Failed to copy static asset: {}", err); return Err(err); } } @@ -134,6 +122,7 @@ pub(crate) fn copy_dir_to( copy_dir_to(entry_path.clone(), output_file_location, pre_compress) { tracing::error!( + dx_src = ?TraceSrc::Build, "Failed to pre-compress directory {}: {}", entry_path.display(), err @@ -149,6 +138,7 @@ pub(crate) fn copy_dir_to( if pre_compress { if let Err(err) = pre_compress_file(&output_file_location) { tracing::error!( + dx_src = ?TraceSrc::Build, "Failed to pre-compress static assets {}: {}", output_file_location.display(), err @@ -206,7 +196,7 @@ pub(crate) fn pre_compress_folder(path: &Path, pre_compress: bool) -> std::io::R if entry_path.is_file() { if pre_compress { if let Err(err) = pre_compress_file(entry_path) { - tracing::error!("Failed to pre-compress file {entry_path:?}: {err}"); + tracing::error!(dx_src = ?TraceSrc::Build, "Failed to pre-compress file {entry_path:?}: {err}"); } } // If pre-compression isn't enabled, we should remove the old compressed file if it exists diff --git a/packages/cli/src/builder/cargo.rs b/packages/cli/src/builder/cargo.rs index 696a48418..804391818 100644 --- a/packages/cli/src/builder/cargo.rs +++ b/packages/cli/src/builder/cargo.rs @@ -12,6 +12,7 @@ use crate::builder::progress::UpdateBuildProgress; use crate::builder::progress::UpdateStage; use crate::link::LinkCommand; use crate::Result; +use crate::TraceSrc; use anyhow::Context; use dioxus_cli_config::Platform; use futures_channel::mpsc::UnboundedSender; @@ -19,6 +20,7 @@ use manganis_cli_support::AssetManifest; use manganis_cli_support::ManganisSupportGuard; use std::fs::create_dir_all; use std::path::PathBuf; +use tracing::error; impl BuildRequest { /// Create a list of arguments for cargo builds @@ -101,7 +103,11 @@ impl BuildRequest { &self, mut progress: UnboundedSender, ) -> Result { - tracing::info!("๐Ÿš… Running build [Desktop] command..."); + tracing::info!( + dx_src = ?TraceSrc::Build, + "Running build [{}] command...", + self.target_platform, + ); // Set up runtime guards let mut dioxus_version = crate::dx_build_info::PKG_VERSION.to_string(); @@ -136,8 +142,9 @@ impl BuildRequest { .context("Failed to post process build")?; tracing::info!( - "๐Ÿšฉ Build completed: [{}]", - self.dioxus_crate.out_dir().display() + dx_src = ?TraceSrc::Build, + "Build completed: [{}]", + self.dioxus_crate.out_dir().display(), ); _ = progress.start_send(UpdateBuildProgress { @@ -222,7 +229,10 @@ impl BuildRequest { cargo_args, Some(linker_args), )?; - let assets = asset_manifest(&build); + let Some(assets) = asset_manifest(&build) else { + error!(dx_src = ?TraceSrc::Build, "the asset manifest was not provided by manganis and we were not able to collect assets"); + return Err(anyhow::anyhow!("asset manifest was not provided by manganis")); + }; // Collect assets from the asset manifest the linker intercept created process_assets(&build, &assets, &mut progress)?; // Create the __assets_head.html file for bundling @@ -235,7 +245,7 @@ impl BuildRequest { } pub fn copy_assets_dir(&self) -> anyhow::Result<()> { - tracing::info!("Copying public assets to the output directory..."); + tracing::info!(dx_src = ?TraceSrc::Build, "Copying public assets to the output directory..."); let out_dir = self.target_out_dir(); let asset_dir = self.dioxus_crate.asset_dir(); diff --git a/packages/cli/src/builder/mod.rs b/packages/cli/src/builder/mod.rs index 8900adba2..82e702a40 100644 --- a/packages/cli/src/builder/mod.rs +++ b/packages/cli/src/builder/mod.rs @@ -1,7 +1,7 @@ -use crate::build::Build; use crate::cli::serve::ServeArguments; use crate::dioxus_crate::DioxusCrate; use crate::Result; +use crate::{build::Build, TraceSrc}; use dioxus_cli_config::{Platform, RuntimeCLIArguments}; use futures_util::stream::select_all; use futures_util::StreamExt; @@ -15,9 +15,7 @@ mod fullstack; mod prepare_html; mod progress; mod web; -pub use progress::{ - BuildMessage, MessageSource, MessageType, Stage, UpdateBuildProgress, UpdateStage, -}; +pub use progress::{Stage, UpdateBuildProgress, UpdateStage}; /// The target platform for the build /// This is very similar to the Platform enum, but we need to be able to differentiate between the @@ -167,13 +165,36 @@ impl BuildResult { fullstack_address: Option, workspace: &std::path::Path, ) -> std::io::Result> { - if self.target_platform == TargetPlatform::Web { - return Ok(None); - } - if self.target_platform == TargetPlatform::Server { - tracing::trace!("Proxying fullstack server from port {fullstack_address:?}"); + match self.target_platform { + TargetPlatform::Web => { + tracing::info!(dx_src = ?TraceSrc::Dev, "Serving web app on http://{} ๐ŸŽ‰", serve.address.address()); + return Ok(None); + } + TargetPlatform::Desktop => { + tracing::info!(dx_src = ?TraceSrc::Dev, "Launching desktop app at {} ๐ŸŽ‰", self.executable.display()); + } + TargetPlatform::Server => { + if let Some(fullstack_address) = fullstack_address { + tracing::info!( + dx_src = ?TraceSrc::Dev, + "Launching fullstack server on http://{:?} ๐ŸŽ‰", + fullstack_address + ); + } + } + TargetPlatform::Liveview => { + if let Some(fullstack_address) = fullstack_address { + tracing::info!( + dx_src = ?TraceSrc::Dev, + "Launching liveview server on http://{:?} ๐ŸŽ‰", + fullstack_address + ); + } + } } + tracing::info!(dx_src = ?TraceSrc::Dev, "Press [o] to open the app manually."); + let arguments = RuntimeCLIArguments::new(serve.address.address(), fullstack_address); let executable = self.executable.canonicalize()?; let mut cmd = Command::new(executable); diff --git a/packages/cli/src/builder/prepare_html.rs b/packages/cli/src/builder/prepare_html.rs index 0b2dcaa6d..7d99dc0e1 100644 --- a/packages/cli/src/builder/prepare_html.rs +++ b/packages/cli/src/builder/prepare_html.rs @@ -1,14 +1,12 @@ //! Build the HTML file to load a web application. The index.html file may be created from scratch or modified from the `index.html` file in the crate root. use super::{BuildRequest, UpdateBuildProgress}; -use crate::builder::progress::MessageSource; -use crate::builder::Stage; use crate::Result; +use crate::TraceSrc; use futures_channel::mpsc::UnboundedSender; use manganis_cli_support::AssetManifest; use std::fmt::Write; use std::path::{Path, PathBuf}; -use tracing::Level; const DEFAULT_HTML: &str = include_str!("../../assets/index.html"); const TOAST_HTML: &str = include_str!("../../assets/toast.html"); @@ -17,12 +15,12 @@ impl BuildRequest { pub(crate) fn prepare_html( &self, assets: Option<&AssetManifest>, - progress: &mut UnboundedSender, + _progress: &mut UnboundedSender, ) -> Result { let mut html = html_or_default(&self.dioxus_crate.crate_dir()); // Inject any resources from the config into the html - self.inject_resources(&mut html, assets, progress)?; + self.inject_resources(&mut html, assets)?; // Inject loading scripts if they are not already present self.inject_loading_scripts(&mut html); @@ -38,12 +36,7 @@ impl BuildRequest { } // Inject any resources from the config into the html - fn inject_resources( - &self, - html: &mut String, - assets: Option<&AssetManifest>, - progress: &mut UnboundedSender, - ) -> Result<()> { + fn inject_resources(&self, html: &mut String, assets: Option<&AssetManifest>) -> Result<()> { // Collect all resources into a list of styles and scripts let resources = &self.dioxus_crate.dioxus_config.web.resource; let mut style_list = resources.style.clone().unwrap_or_default(); @@ -65,7 +58,7 @@ impl BuildRequest { } if !style_list.is_empty() { - self.send_resource_deprecation_warning(progress, style_list, ResourceType::Style); + self.send_resource_deprecation_warning(style_list, ResourceType::Style); } // Add all scripts to the head @@ -78,7 +71,7 @@ impl BuildRequest { } if !script_list.is_empty() { - self.send_resource_deprecation_warning(progress, script_list, ResourceType::Script); + self.send_resource_deprecation_warning(script_list, ResourceType::Script); } // Inject any resources from manganis into the head @@ -139,13 +132,8 @@ impl BuildRequest { *html = html.replace("{app_name}", app_name); } - fn send_resource_deprecation_warning( - &self, - progress: &mut UnboundedSender, - paths: Vec, - variant: ResourceType, - ) { - const RESOURCE_DEPRECATION_MESSAGE: &str = r#"The `web.resource` config has been deprecated in favor of head components and will be removed in a future release. Instead of including assets in the config, you can include assets with the `asset!` macro and add them to the head with `head::Link` and `Script` components."#; + fn send_resource_deprecation_warning(&self, paths: Vec, variant: ResourceType) { + const RESOURCE_DEPRECATION_MESSAGE: &str = r#"The `web.resource` config has been deprecated in favor of head components and will be removed in a future release."#; let replacement_components = paths .iter() @@ -187,14 +175,7 @@ impl BuildRequest { "{RESOURCE_DEPRECATION_MESSAGE}\nTo migrate to head components, remove `{section_name}` and include the following rsx in your root component:\n```rust\n{replacement_components}\n```" ); - _ = progress.unbounded_send(UpdateBuildProgress { - stage: Stage::OptimizingWasm, - update: super::UpdateStage::AddMessage(super::BuildMessage { - level: Level::WARN, - message: super::MessageType::Text(message), - source: MessageSource::Build, - }), - }); + tracing::warn!(dx_src = ?TraceSrc::Build, "{}", message); } } diff --git a/packages/cli/src/builder/progress.rs b/packages/cli/src/builder/progress.rs index e5f3ae73b..d4966859b 100644 --- a/packages/cli/src/builder/progress.rs +++ b/packages/cli/src/builder/progress.rs @@ -1,16 +1,15 @@ //! Report progress about the build to the user. We use channels to report progress back to the CLI. +use crate::TraceSrc; + +use super::BuildRequest; use anyhow::Context; -use cargo_metadata::{diagnostic::Diagnostic, Message}; +use cargo_metadata::Message; use futures_channel::mpsc::UnboundedSender; use serde::Deserialize; -use std::fmt::Display; use std::ops::Deref; use std::path::PathBuf; use std::process::Stdio; use tokio::io::AsyncBufReadExt; -use tracing::Level; - -use super::BuildRequest; #[derive(Default, Debug, PartialOrd, Ord, PartialEq, Eq, Clone, Copy)] pub enum Stage { @@ -54,14 +53,6 @@ impl UpdateBuildProgress { pub fn to_std_out(&self) { match &self.update { UpdateStage::Start => println!("--- {} ---", self.stage), - UpdateStage::AddMessage(message) => match &message.message { - MessageType::Cargo(message) => { - println!("{}", message.rendered.clone().unwrap_or_default()); - } - MessageType::Text(message) => { - println!("{}", message); - } - }, UpdateStage::SetProgress(progress) => { println!("Build progress {:0.0}%", progress * 100.0); } @@ -75,70 +66,10 @@ impl UpdateBuildProgress { #[derive(Debug, Clone, PartialEq)] pub enum UpdateStage { Start, - AddMessage(BuildMessage), SetProgress(f64), Failed(String), } -#[derive(Debug, Clone, PartialEq)] -pub struct BuildMessage { - pub level: Level, - pub message: MessageType, - pub source: MessageSource, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum MessageType { - Cargo(Diagnostic), - Text(String), -} - -/// Represents the source of where a message came from. -/// -/// The CLI will render a prefix according to the message type -/// but this prefix, [`MessageSource::to_string()`] shouldn't be used if a strict message source is required. -#[derive(Debug, Clone, PartialEq)] -pub enum MessageSource { - /// Represents any message from the running application. Renders `[app]` - App, - /// Represents any generic message from the CLI. Renders `[dev]` - /// - /// Usage of Tracing inside of the CLI will be routed to this type. - Dev, - /// Represents a message from the build process. Renders `[bld]` - /// - /// This is anything emitted from a build process such as cargo and optimizations. - Build, -} - -impl Display for MessageSource { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::App => write!(f, "app"), - Self::Dev => write!(f, "dev"), - Self::Build => write!(f, "bld"), - } - } -} - -impl From for BuildMessage { - fn from(message: Diagnostic) -> Self { - Self { - level: match message.level { - cargo_metadata::diagnostic::DiagnosticLevel::Ice - | cargo_metadata::diagnostic::DiagnosticLevel::FailureNote - | cargo_metadata::diagnostic::DiagnosticLevel::Error => Level::ERROR, - cargo_metadata::diagnostic::DiagnosticLevel::Warning => Level::WARN, - cargo_metadata::diagnostic::DiagnosticLevel::Note => Level::INFO, - cargo_metadata::diagnostic::DiagnosticLevel::Help => Level::DEBUG, - _ => Level::DEBUG, - }, - source: MessageSource::Build, - message: MessageType::Cargo(message), - } - } -} - pub(crate) async fn build_cargo( crate_count: usize, mut cmd: tokio::process::Command, @@ -183,10 +114,8 @@ pub(crate) async fn build_cargo( match message { Message::CompilerMessage(msg) => { let message = msg.message; - _ = progress.start_send(UpdateBuildProgress { - stage: Stage::Compiling, - update: UpdateStage::AddMessage(message.clone().into()), - }); + tracing::info!(dx_src = ?TraceSrc::Cargo, dx_no_fmt = true, "{}", message.to_string()); + const WARNING_LEVELS: &[cargo_metadata::diagnostic::DiagnosticLevel] = &[ cargo_metadata::diagnostic::DiagnosticLevel::Help, cargo_metadata::diagnostic::DiagnosticLevel::Note, @@ -230,14 +159,7 @@ pub(crate) async fn build_cargo( } } Message::TextLine(line) => { - _ = progress.start_send(UpdateBuildProgress { - stage: Stage::Compiling, - update: UpdateStage::AddMessage(BuildMessage { - level: Level::DEBUG, - message: MessageType::Text(line), - source: MessageSource::Build, - }), - }); + tracing::info!(dx_src = ?TraceSrc::Cargo, dx_no_fmt = true, "{}", line); } _ => { // Unknown message diff --git a/packages/cli/src/builder/web.rs b/packages/cli/src/builder/web.rs index bb3f7e5e7..0354d8637 100644 --- a/packages/cli/src/builder/web.rs +++ b/packages/cli/src/builder/web.rs @@ -5,6 +5,7 @@ use crate::builder::progress::Stage; use crate::builder::progress::UpdateBuildProgress; use crate::builder::progress::UpdateStage; use crate::error::{Error, Result}; +use crate::TraceSrc; use futures_channel::mpsc::UnboundedSender; use manganis_cli_support::AssetManifest; use std::path::Path; @@ -14,7 +15,7 @@ use wasm_bindgen_cli_support::Bindgen; // Attempt to automatically recover from a bindgen failure by updating the wasm-bindgen version async fn update_wasm_bindgen_version() -> Result<()> { let cli_bindgen_version = wasm_bindgen_shared::version(); - tracing::info!("Attempting to recover from bindgen failure by setting the wasm-bindgen version to {cli_bindgen_version}..."); + tracing::info!(dx_src = ?TraceSrc::Build, "Attempting to recover from bindgen failure by setting the wasm-bindgen version to {cli_bindgen_version}..."); let output = Command::new("cargo") .args([ @@ -29,7 +30,7 @@ async fn update_wasm_bindgen_version() -> Result<()> { let mut error_message = None; if let Ok(output) = output { if output.status.success() { - tracing::info!("Successfully updated wasm-bindgen to {cli_bindgen_version}"); + tracing::info!(dx_src = ?TraceSrc::Dev, "Successfully updated wasm-bindgen to {cli_bindgen_version}"); return Ok(()); } else { error_message = Some(output); @@ -37,7 +38,7 @@ async fn update_wasm_bindgen_version() -> Result<()> { } if let Some(output) = error_message { - tracing::error!("Failed to update wasm-bindgen: {:#?}", output); + tracing::error!(dx_src = ?TraceSrc::Dev, "Failed to update wasm-bindgen: {:#?}", output); } Err(Error::BuildFailed(format!("WASM bindgen build failed!\nThis is probably due to the Bindgen version, dioxus-cli is using `{cli_bindgen_version}` which is not compatible with your crate.\nPlease reinstall the dioxus cli to fix this issue.\nYou can reinstall the dioxus cli by running `cargo install dioxus-cli --force` and then rebuild your project"))) @@ -57,7 +58,7 @@ pub(crate) async fn install_web_build_tooling( stage: Stage::InstallingWasmTooling, update: UpdateStage::Start, }); - tracing::info!("wasm32-unknown-unknown target not detected, installing.."); + tracing::info!(dx_src = ?TraceSrc::Build, "`wasm32-unknown-unknown` target not detected, installing.."); let _ = Command::new("rustup") .args(["target", "add", "wasm32-unknown-unknown"]) .output() @@ -70,7 +71,7 @@ pub(crate) async fn install_web_build_tooling( impl BuildRequest { async fn run_wasm_bindgen(&self, input_path: &Path, bindgen_outdir: &Path) -> Result<()> { - tracing::info!("Running wasm-bindgen"); + tracing::info!(dx_src = ?TraceSrc::Build, "Running wasm-bindgen"); let input_path = input_path.to_path_buf(); let bindgen_outdir = bindgen_outdir.to_path_buf(); let keep_debug = @@ -99,7 +100,7 @@ impl BuildRequest { // WASM bindgen requires the exact version of the bindgen schema to match the version the CLI was built with // If we get an error, we can try to recover by pinning the user's wasm-bindgen version to the version we used if let Err(err) = bindgen_result { - tracing::error!("Bindgen build failed: {:?}", err); + tracing::error!(dx_src = ?TraceSrc::Build, "Bindgen build failed: {:?}", err); update_wasm_bindgen_version().await?; run_wasm_bindgen(); } @@ -137,7 +138,7 @@ impl BuildRequest { if self.build_arguments.release { use dioxus_cli_config::WasmOptLevel; - tracing::info!("Running optimization with wasm-opt..."); + tracing::info!(dx_src = ?TraceSrc::Build, "Running optimization with wasm-opt..."); let mut options = match self.dioxus_crate.dioxus_config.web.wasm_opt.level { WasmOptLevel::Z => { wasm_opt::OptimizationOptions::new_optimize_for_size_aggressively() @@ -162,6 +163,7 @@ impl BuildRequest { .map_err(|err| Error::Other(anyhow::anyhow!(err)))?; let new_size = wasm_file.metadata()?.len(); tracing::info!( + dx_src = ?TraceSrc::Build, "wasm-opt reduced WASM size from {} to {} ({:2}%)", old_size, new_size, diff --git a/packages/cli/src/cli/config.rs b/packages/cli/src/cli/config.rs index 8bf56a528..a1d9916e6 100644 --- a/packages/cli/src/cli/config.rs +++ b/packages/cli/src/cli/config.rs @@ -1,4 +1,5 @@ use crate::build::TargetArgs; +use crate::TraceSrc; use crate::{metadata::crate_root, CliSettings}; use super::*; @@ -26,6 +27,9 @@ pub enum Config { /// Create a custom html file. CustomHtml {}, + /// Print the location of the CLI log file. + LogFile {}, + /// Set CLI settings. #[command(subcommand)] Set(Setting), @@ -92,7 +96,7 @@ impl Config { .replace("{{project-name}}", &name) .replace("{{default-platform}}", &platform); file.write_all(content.as_bytes())?; - tracing::info!("๐Ÿšฉ Init config file completed."); + tracing::info!(dx_src = ?TraceSrc::Dev, "๐Ÿšฉ Init config file completed."); } Config::FormatPrint {} => { println!( @@ -105,7 +109,11 @@ impl Config { let mut file = File::create(html_path)?; let content = include_str!("../../assets/index.html"); file.write_all(content.as_bytes())?; - tracing::info!("๐Ÿšฉ Create custom html file done."); + tracing::info!(dx_src = ?TraceSrc::Dev, "๐Ÿšฉ Create custom html file done."); + } + Config::LogFile {} => { + let log_path = crate::tracer::log_path(); + tracing::info!(dx_src = ?TraceSrc::Dev, "Log file is located at {}", log_path.display()); } // Handle CLI settings. Config::Set(setting) => { @@ -121,7 +129,7 @@ impl Config { settings.wsl_file_poll_interval = Some(value) } })?; - tracing::info!("๐Ÿšฉ CLI setting `{setting}` has been set."); + tracing::info!(dx_src = ?TraceSrc::Dev, "๐Ÿšฉ CLI setting `{setting}` has been set."); } } Ok(()) diff --git a/packages/cli/src/cli/create.rs b/packages/cli/src/cli/create.rs index c859aa1ff..eaac21395 100644 --- a/packages/cli/src/cli/create.rs +++ b/packages/cli/src/cli/create.rs @@ -1,4 +1,5 @@ use super::*; +use crate::TraceSrc; use cargo_generate::{GenerateArgs, TemplatePath}; use cargo_metadata::Metadata; use std::path::Path; @@ -134,9 +135,9 @@ pub fn post_create(path: &Path, metadata: Option) -> Result<()> { let cmd = cmd.arg("fmt").current_dir(path); let output = cmd.output().expect("failed to execute process"); if !output.status.success() { - tracing::error!("cargo fmt failed"); - tracing::error!("stdout: {}", String::from_utf8_lossy(&output.stdout)); - tracing::error!("stderr: {}", String::from_utf8_lossy(&output.stderr)); + tracing::error!(dx_src = ?TraceSrc::Dev, "cargo fmt failed"); + tracing::error!(dx_src = ?TraceSrc::Build, "stdout: {}", String::from_utf8_lossy(&output.stdout)); + tracing::error!(dx_src = ?TraceSrc::Build, "stderr: {}", String::from_utf8_lossy(&output.stderr)); } // 3. Format the `Cargo.toml` and `Dioxus.toml` files. @@ -166,7 +167,7 @@ pub fn post_create(path: &Path, metadata: Option) -> Result<()> { let mut file = std::fs::File::create(readme_path)?; file.write_all(new_readme.as_bytes())?; - tracing::info!("Generated project at {}", path.display()); + tracing::info!(dx_src = ?TraceSrc::Dev, "Generated project at {}", path.display()); Ok(()) } diff --git a/packages/cli/src/cli/serve.rs b/packages/cli/src/cli/serve.rs index f0ac6a829..a55e19414 100644 --- a/packages/cli/src/cli/serve.rs +++ b/packages/cli/src/cli/serve.rs @@ -1,8 +1,4 @@ -use crate::{ - settings::{self}, - tracer::CLILogControl, - DioxusCrate, -}; +use crate::{settings, tracer::CLILogControl, DioxusCrate}; use anyhow::Context; use build::Build; use dioxus_cli_config::AddressArguments; diff --git a/packages/cli/src/cli/translate.rs b/packages/cli/src/cli/translate.rs index 4c8b48518..9383ffb26 100644 --- a/packages/cli/src/cli/translate.rs +++ b/packages/cli/src/cli/translate.rs @@ -2,6 +2,8 @@ use std::process::exit; use dioxus_rsx::{BodyNode, CallBody, TemplateBody}; +use crate::TraceSrc; + use super::*; /// Translate some source file into Dioxus code @@ -106,7 +108,7 @@ fn indent_and_write(raw: &str, idx: usize, out: &mut String) { fn determine_input(file: Option, raw: Option) -> Result { // Make sure not both are specified if file.is_some() && raw.is_some() { - tracing::error!("Only one of --file or --raw should be specified."); + tracing::error!(dx_src = ?TraceSrc::Dev, "Only one of --file or --raw should be specified."); exit(0); } diff --git a/packages/cli/src/main.rs b/packages/cli/src/main.rs index 995436489..3e6c69e42 100644 --- a/packages/cli/src/main.rs +++ b/packages/cli/src/main.rs @@ -3,26 +3,22 @@ #![doc(html_favicon_url = "https://avatars.githubusercontent.com/u/79236386")] pub mod assets; +pub mod builder; +pub mod cli; +pub mod dioxus_crate; pub mod dx_build_info; +pub mod error; +pub mod metadata; pub mod serve; +pub mod settings; pub mod tools; pub mod tracer; -pub mod cli; -pub use cli::*; - -pub mod error; -pub use error::*; - -pub(crate) mod builder; - -mod dioxus_crate; -pub use dioxus_crate::*; - -mod settings; +pub(crate) use cli::*; +pub(crate) use dioxus_crate::*; +pub(crate) use error::*; pub(crate) use settings::*; - -pub(crate) mod metadata; +pub(crate) use tracer::{TraceMsg, TraceSrc}; use anyhow::Context; use clap::Parser; diff --git a/packages/cli/src/serve/mod.rs b/packages/cli/src/serve/mod.rs index b2e13e57d..10e0868ae 100644 --- a/packages/cli/src/serve/mod.rs +++ b/packages/cli/src/serve/mod.rs @@ -1,11 +1,14 @@ use std::future::{poll_fn, Future, IntoFuture}; use std::task::Poll; -use crate::builder::{Stage, TargetPlatform, UpdateBuildProgress, UpdateStage}; use crate::cli::serve::Serve; use crate::dioxus_crate::DioxusCrate; use crate::tracer::CLILogControl; use crate::Result; +use crate::{ + builder::{Stage, TargetPlatform, UpdateBuildProgress, UpdateStage}, + TraceSrc, +}; use futures_util::FutureExt; use tokio::task::yield_now; @@ -54,6 +57,8 @@ pub async fn serve_all( dioxus_crate: DioxusCrate, log_control: CLILogControl, ) -> Result<()> { + // Start the screen first so we collect build logs. + let mut screen = Output::start(&serve, log_control).expect("Failed to open terminal logger"); let mut builder = Builder::new(&dioxus_crate, &serve); // Start the first build @@ -61,7 +66,6 @@ pub async fn serve_all( let mut server = Server::start(&serve, &dioxus_crate); let mut watcher = Watcher::start(&serve, &dioxus_crate); - let mut screen = Output::start(&serve, log_control).expect("Failed to open terminal logger"); let is_hot_reload = serve.server_arguments.hot_reload.unwrap_or(true); @@ -81,15 +85,21 @@ pub async fn serve_all( } let changed_files = watcher.dequeue_changed_files(&dioxus_crate); + let changed = changed_files.first().cloned(); // if change is hotreloadable, hotreload it // and then send that update to all connected clients if let Some(hr) = watcher.attempt_hot_reload(&dioxus_crate, changed_files) { // Only send a hotreload message for templates and assets - otherwise we'll just get a full rebuild - if hr.templates.is_empty() && hr.assets.is_empty() { + if hr.templates.is_empty() && hr.assets.is_empty() && hr.unknown_files.is_empty() { continue } + if let Some(changed_path) = changed { + let path_relative = changed_path.strip_prefix(dioxus_crate.crate_dir()).map(|p| p.display().to_string()).unwrap_or_else(|_| changed_path.display().to_string()); + tracing::info!(dx_src = ?TraceSrc::Dev, "Hotreloaded {}", path_relative); + } + server.send_hotreload(hr).await; } else { // If the change is not binary patchable, rebuild the project @@ -129,7 +139,7 @@ pub async fn serve_all( match application { Ok(BuilderUpdate::Progress { platform, update }) => { let update_clone = update.clone(); - screen.new_build_logs(platform, update_clone); + screen.new_build_progress(platform, update_clone); server.update_build_status(screen.build_progress.progress(), update.stage.to_string()).await; match update { @@ -151,7 +161,7 @@ pub async fn serve_all( match child { Ok(Some(child_proc)) => builder.children.push((build_result.target_platform, child_proc)), Err(e) => { - tracing::error!("Failed to open build result: {e}"); + tracing::error!(dx_src = ?TraceSrc::Build, "Failed to open build result: {e}"); break; }, _ => {} @@ -176,11 +186,11 @@ pub async fn serve_all( break; } else { - tracing::error!("Application exited with status: {status}"); + tracing::error!(dx_src = ?TraceSrc::Dev, "Application exited with status: {status}"); } }, Err(e) => { - tracing::error!("Application exited with error: {e}"); + tracing::error!(dx_src = ?TraceSrc::Dev, "Application exited with error: {e}"); } } } diff --git a/packages/cli/src/serve/output.rs b/packages/cli/src/serve/output.rs index 1810178d4..75d4a13c3 100644 --- a/packages/cli/src/serve/output.rs +++ b/packages/cli/src/serve/output.rs @@ -1,30 +1,30 @@ -use crate::{ - builder::{ - BuildMessage, MessageSource, MessageType, Stage, TargetPlatform, UpdateBuildProgress, - }, - dioxus_crate::DioxusCrate, - serve::next_or_pending, - tracer::CLILogControl, -}; 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 core::panic; use crossterm::{ - event::{Event, EventStream, KeyCode, KeyModifiers, MouseEventKind}, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + 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_cli_config::{AddressArguments, Platform}; use dioxus_hot_reload::ClientMsg; use futures_util::{future::select_all, Future, FutureExt, StreamExt}; -use ratatui::{prelude::*, widgets::*, TerminalOptions, Viewport}; +use ratatui::{prelude::*, TerminalOptions, Viewport}; use std::{ cell::RefCell, collections::{HashMap, HashSet}, - fmt::Display, io::{self, stdout}, rc::Rc, sync::atomic::Ordering, @@ -36,38 +36,23 @@ use tokio::{ }; use tracing::Level; -use super::{Builder, Server, Watcher}; +mod render; -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum LogSource { - Internal, - Target(TargetPlatform), -} - -impl Display for LogSource { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - LogSource::Internal => write!(f, "CLI"), - LogSource::Target(platform) => write!(f, "{platform}"), - } - } -} - -impl From for LogSource { - fn from(platform: TargetPlatform) -> Self { - LogSource::Target(platform) - } -} +// 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 { - internal_logs: Vec, - build_logs: HashMap, + current_builds: HashMap, } impl BuildProgress { pub fn progress(&self) -> f64 { - self.build_logs + self.current_builds .values() .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) .map(|build| match build.stage { @@ -87,30 +72,35 @@ pub struct Output { // 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, - interactive: bool, - pub(crate) build_progress: BuildProgress, - running_apps: HashMap, - is_cli_release: bool, - platform: Platform, - - num_lines_with_wrapping: u16, - term_height: u16, - scroll: u16, - fly_modal_open: bool, - anim_start: Instant, - - tab: Tab, - - addr: AddressArguments, -} - -#[derive(PartialEq, Eq, Clone, Copy)] -enum Tab { - Console, - BuildLog, } type TerminalBackend = Terminal>; @@ -122,9 +112,9 @@ impl Output { let mut events = None; if interactive { - log_control.tui_enabled.store(true, Ordering::SeqCst); + log_control.output_enabled.store(true, Ordering::SeqCst); enable_raw_mode()?; - stdout().execute(EnterAlternateScreen)?; + stdout().execute(EnterAlternateScreen)?.execute(Hide)?; // workaround for ci where the terminal is not fully initialized // this stupid bug @@ -135,6 +125,9 @@ impl Output { // 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 { @@ -153,8 +146,8 @@ impl Output { 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]; @@ -173,24 +166,30 @@ impl Output { _rustc_nightly, _dx_version: dx_version, interactive, - is_cli_release, + _is_cli_release: is_cli_release, platform, - fly_modal_open: false, + messages: Vec::new(), + more_modal_open: false, build_progress: Default::default(), running_apps: HashMap::new(), - scroll: 0, - term_height: 0, - num_lines_with_wrapping: 0, + scroll_position: 0, + num_lines_wrapping: 0, + console_width: 0, + console_height: 0, anim_start: Instant::now(), - tab: Tab::BuildLog, 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.set_tab(Tab::BuildLog); - self.running_apps .get_mut(&platform) .unwrap() @@ -199,16 +198,16 @@ impl Output { .unwrap() .stderr_line .push_str(&stderr); - self.build_progress - .build_logs - .get_mut(&platform) - .unwrap() - .messages - .push(BuildMessage { - level: Level::ERROR, - message: MessageType::Text(stderr), - source: MessageSource::App, - }); + + 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 @@ -221,16 +220,16 @@ impl Output { .unwrap() .stdout_line .push_str(&stdout); - self.build_progress - .build_logs - .get_mut(&platform) - .unwrap() - .messages - .push(BuildMessage { - level: Level::INFO, - message: MessageType::Text(stdout), - source: MessageSource::App, - }); + + 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 @@ -278,7 +277,7 @@ impl Output { } }; - let tui_log_rx = &mut self.log_control.tui_rx; + let tui_log_rx = &mut self.log_control.output_rx; let next_tui_log = next_or_pending(tui_log_rx.next()); tokio::select! { @@ -293,11 +292,7 @@ impl Output { // Handle internal CLI tracing logs. log = next_tui_log => { - self.push_log(LogSource::Internal, BuildMessage { - level: Level::INFO, - message: MessageType::Text(log), - source: MessageSource::Dev, - }); + self.push_log(log); } event = user_input => { @@ -313,9 +308,11 @@ impl Output { 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.tui_enabled.store(false, Ordering::SeqCst); + self.log_control + .output_enabled + .store(false, Ordering::SeqCst); disable_raw_mode()?; - stdout().execute(LeaveAlternateScreen)?; + stdout().execute(LeaveAlternateScreen)?.execute(Show)?; self.drain_print_logs(); } @@ -328,37 +325,16 @@ impl Output { /// versions of the cli would just eat build logs making debugging issues harder than they needed /// to be. fn drain_print_logs(&mut self) { - fn log_build_message(platform: &LogSource, message: &BuildMessage) { - match &message.message { - MessageType::Text(text) => { - for line in text.lines() { - println!("{platform}: {line}"); - } - } - MessageType::Cargo(diagnostic) => { - println!("{platform}: {diagnostic}"); - } + 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); } } - - // todo: print the build info here for the most recent build, and then the logs of the most recent build - for (platform, build) in self.build_progress.build_logs.iter_mut() { - if build.messages.is_empty() { - continue; - } - - let messages = build.messages.drain(0..); - - for message in messages { - log_build_message(&LogSource::Target(*platform), &message); - } - } - - // Log the internal logs - let messaegs = self.build_progress.internal_logs.drain(..); - for message in messaegs { - log_build_message(&LogSource::Internal, &message); - } } /// Handle an input event, returning `true` if the event should cause the program to restart. @@ -372,59 +348,138 @@ impl Output { } } - if let Event::Key(key) = input { - if let KeyCode::Char('/') = key.code { - self.fly_modal_open = !self.fly_modal_open; + // 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::Mouse(mouse) if mouse.kind == MouseEventKind::ScrollUp => { - self.scroll = self.scroll.saturating_sub(1); + 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::Mouse(mouse) if mouse.kind == MouseEventKind::ScrollDown => { - self.scroll += 1; + 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::Up => { - self.scroll = self.scroll.saturating_sub(1); + 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::Down => { - self.scroll += 1; + 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::Char('r') => { - // todo: reload the app + 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') => { + 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('c') => { - // Clear the currently selected build logs. - for build in self.build_progress.build_logs.values_mut() { - let msgs = match self.tab { - Tab::Console => &mut build.stdout_logs, - Tab::BuildLog => &mut build.messages, - }; - msgs.clear(); + + 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::Key(key) if key.code == KeyCode::Char('1') => self.set_tab(Tab::Console), - Event::Key(key) if key.code == KeyCode::Char('2') => self.set_tab(Tab::BuildLog), Event::Resize(_width, _height) => { // nothing, it should take care of itself } _ => {} } - if self.scroll - > self - .num_lines_with_wrapping - .saturating_sub(self.term_height + 1) - { - self.scroll = self - .num_lines_with_wrapping - .saturating_sub(self.term_height + 1); + if self.scroll_position > self.num_lines_wrapping.saturating_sub(self.console_height) { + self.scroll_to_bottom(); } Ok(false) @@ -435,92 +490,56 @@ impl Output { 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 }) => { - self.push_log( - platform, - BuildMessage { - level: match level.as_str() { - "info" => Level::INFO, - "warn" => Level::WARN, - "error" => Level::ERROR, - "debug" => Level::DEBUG, - _ => Level::INFO, - }, - message: MessageType::Text( - // todo: the js console is giving us a list of params, not formatted text - // we need to translate its styling into our own - messages.first().unwrap_or(&String::new()).clone(), - ), - source: MessageSource::App, - }, - ); + 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) => { - self.push_log( - platform, - BuildMessage { - level: Level::ERROR, - source: MessageSource::Dev, - message: MessageType::Text(format!("Error parsing app message: {err}")), - }, - ); + tracing::error!(dx_src = ?TraceSrc::Dev, "Error parsing message from {}: {}", platform, err); } } } } - // todo: re-enable - #[allow(unused)] - fn is_snapped(&self, _platform: LogSource) -> bool { + fn is_snapped(&self) -> bool { true - // let prev_scrol = self - // .num_lines_with_wrapping - // .saturating_sub(self.term_height); - // prev_scrol == self.scroll } pub fn scroll_to_bottom(&mut self) { - self.scroll = (self.num_lines_with_wrapping).saturating_sub(self.term_height); + self.scroll_position = self.num_lines_wrapping.saturating_sub(self.console_height); } - pub fn push_log(&mut self, platform: impl Into, message: BuildMessage) { - let source = platform.into(); - let snapped = self.is_snapped(source); + pub fn push_log(&mut self, message: TraceMsg) { + self.messages.push(message); - match source { - LogSource::Internal => self.build_progress.internal_logs.push(message), - LogSource::Target(platform) => self - .build_progress - .build_logs - .entry(platform) - .or_default() - .stdout_logs - .push(message), - } - - if snapped { + if self.is_snapped() { self.scroll_to_bottom(); } } - pub fn new_build_logs(&mut self, platform: TargetPlatform, update: UpdateBuildProgress) { - let snapped = self.is_snapped(LogSource::Target(platform)); - - // when the build is finished, switch to the console - if update.stage == Stage::Finished { - self.tab = Tab::Console; - } - + pub fn new_build_progress(&mut self, platform: TargetPlatform, update: UpdateBuildProgress) { self.build_progress - .build_logs + .current_builds .entry(platform) .or_default() .update(update); - if snapped { + if self.is_snapped() { self.scroll_to_bottom(); } } @@ -557,7 +576,7 @@ impl Output { self.running_apps.insert(platform, app); // Finish the build progress for the platform that just finished building - if let Some(build) = self.build_progress.build_logs.get_mut(&platform) { + if let Some(build) = self.build_progress.current_builds.get_mut(&platform) { build.stage = Stage::Finished; } } @@ -568,7 +587,7 @@ impl Output { _opts: &Serve, _config: &DioxusCrate, _build_engine: &Builder, - server: &Server, + _server: &Server, _watcher: &Watcher, ) { // just drain the build logs @@ -590,230 +609,59 @@ impl Output { .as_mut() .unwrap() .draw(|frame| { - // a layout that has a title with stats about the program and then the actual console itself - let body = Layout::default() - .direction(Direction::Vertical) - .constraints( - [ - // Title - Constraint::Length(1), - // Body - Constraint::Min(0), - ] - .as_ref(), - ) - .split(frame.size()); + 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; - // Split the body into a left and a right - let console = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Fill(1), Constraint::Length(14)].as_ref()) - .split(body[1]); + // 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); - let addr = format!("http://{}:{}", self.addr.addr, self.addr.port); - let listening_len = format!("listening at {addr}").len() + 3; - let listening_len = if listening_len > body[0].width as usize { - 0 - } else { - listening_len - }; + // 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::>(); - let header = Layout::default() - .direction(Direction::Horizontal) - .constraints( - [ - Constraint::Fill(1), - Constraint::Length(listening_len as u16), - ] - .as_ref(), - ) - .split(body[0]); + // 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, + ); - // // Render a border for the header - // frame.render_widget(Block::default().borders(Borders::BOTTOM), body[0]); - - // Render the metadata - let mut spans = vec![ - Span::from(if self.is_cli_release { "dx" } else { "dx-dev" }).green(), - Span::from(" ").green(), - Span::from("serve").green(), - Span::from(" | ").white(), - Span::from(self.platform.to_string()).green(), - Span::from(" | ").white(), - ]; - - // If there is build progress, display that next to the platform - if !self.build_progress.build_logs.is_empty() { - if self - .build_progress - .build_logs - .values() - .any(|b| b.failed.is_some()) - { - spans.push(Span::from("build failed โŒ").red()); - } else { - spans.push(Span::from("status: ").green()); - let build = self - .build_progress - .build_logs - .values() - .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) - .unwrap(); - spans.extend_from_slice(&build.spans(Rect::new( - 0, - 0, - build.max_layout_size(), - 1, - ))); - } - } - - frame.render_widget(Paragraph::new(Line::from(spans)).left_aligned(), header[0]); - - // Split apart the body into a center and a right side - // We only want to show the sidebar if there's enough space - if listening_len > 0 { - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::from("listening at ").dark_gray(), - Span::from(format!("http://{}", server.ip).as_str()).gray(), - ])), - header[1], + 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(), ); } - // Draw the tabs in the right region of the console - // First draw the left border - frame.render_widget( - Paragraph::new(vec![ - { - let mut line = Line::from(" [1] console").dark_gray(); - if self.tab == Tab::Console { - line.style = Style::default().fg(Color::LightYellow); - } - line - }, - { - let mut line = Line::from(" [2] build").dark_gray(); - if self.tab == Tab::BuildLog { - line.style = Style::default().fg(Color::LightYellow); - } - line - }, - Line::from(" ").gray(), - Line::from(" [/] more").gray(), - Line::from(" [r] rebuild").gray(), - Line::from(" [c] clear").gray(), - Line::from(" [o] open").gray(), - Line::from(" [h] hide").gray(), - ]) - .left_aligned() - .block( - Block::default() - .borders(Borders::LEFT | Borders::TOP) - .border_set(symbols::border::Set { - top_left: symbols::line::NORMAL.horizontal_down, - ..symbols::border::PLAIN - }), - ), - console[1], + layout.render_status_bar( + frame, + self.platform, + &self.build_progress, + self.more_modal_open, + self.show_filter_menu, + &self._dx_version, ); - // We're going to assemble a text buffer directly and then let the paragraph widgets - // handle the wrapping and scrolling - let mut paragraph_text: Text<'_> = Text::default(); - - let mut add_build_message = |message: &BuildMessage| { - use ansi_to_tui::IntoText; - match &message.message { - MessageType::Text(line) => { - for line in line.lines() { - let text = line.into_text().unwrap_or_default(); - for line in text.lines { - let source = format!("[{}] ", message.source); - - let msg_span = Span::from(source); - let msg_span = match message.source { - MessageSource::App => msg_span.light_blue(), - MessageSource::Dev => msg_span.dark_gray(), - MessageSource::Build => msg_span.light_yellow(), - }; - - let mut out_line = vec![msg_span]; - for span in line.spans { - out_line.push(span); - } - let newline = Line::from(out_line); - paragraph_text.push_line(newline); - } - } - } - MessageType::Cargo(diagnostic) => { - let diagnostic = diagnostic.rendered.as_deref().unwrap_or_default(); - - for line in diagnostic.lines() { - paragraph_text.extend(line.into_text().unwrap_or_default()); - } - } - }; - }; - - // First log each platform's build logs - for platform in self.build_progress.build_logs.keys() { - let build = self.build_progress.build_logs.get(platform).unwrap(); - - let msgs = match self.tab { - Tab::Console => &build.stdout_logs, - Tab::BuildLog => &build.messages, - }; - - for span in msgs.iter() { - add_build_message(span); - } - } - // Then log the internal logs - for message in self.build_progress.internal_logs.iter() { - add_build_message(message); + if self.more_modal_open { + layout.render_more_modal(frame); } - let paragraph = Paragraph::new(paragraph_text) - .left_aligned() - .wrap(Wrap { trim: false }); - - self.term_height = console[0].height; - self.num_lines_with_wrapping = paragraph.line_count(console[0].width) as u16; - - let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(None) - .end_symbol(None) - .track_symbol(None) - .thumb_symbol("โ–"); - - let mut scrollbar_state = ScrollbarState::new( - self.num_lines_with_wrapping - .saturating_sub(self.term_height) as usize, - ) - .position(self.scroll as usize); - - let paragraph = paragraph.scroll((self.scroll, 0)); - paragraph - .block(Block::new().borders(Borders::TOP)) - .render(console[0], frame.buffer_mut()); - - // and the scrollbar, those are separate widgets - frame.render_stateful_widget( - scrollbar, - console[0].inner(Margin { - // todo: dont use margin - just push down the body based on its top border - // using an inner vertical margin of 1 unit makes the scrollbar inside the block - vertical: 1, - horizontal: 0, - }), - &mut scrollbar_state, + layout.render_current_scroll( + self.scroll_position, + self.num_lines_wrapping, + self.console_height, + frame, ); - - // render the fly modal - self.render_fly_modal(frame, console[0]); }); } @@ -848,38 +696,11 @@ impl Output { Ok(false) } - - fn render_fly_modal(&mut self, frame: &mut Frame, area: Rect) { - if !self.fly_modal_open { - return; - } - - // Create a frame slightly smaller than the area - let panel = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Fill(1)].as_ref()) - .split(area)[0]; - - // Wipe the panel - frame.render_widget(Clear, panel); - frame.render_widget(Block::default().borders(Borders::ALL), panel); - - let modal = Paragraph::new("Under construction, please check back at a later date!\n") - .alignment(Alignment::Center); - frame.render_widget(modal, panel); - } - - fn set_tab(&mut self, tab: Tab) { - self.tab = tab; - self.scroll = 0; - } } #[derive(Default, Debug, PartialEq)] pub struct ActiveBuild { stage: Stage, - messages: Vec, - stdout_logs: Vec, progress: f64, failed: Option, } @@ -896,9 +717,6 @@ impl ActiveBuild { self.progress = 0.0; self.failed = None; } - UpdateStage::AddMessage(message) => { - self.messages.push(message); - } UpdateStage::SetProgress(progress) => { self.progress = progress; } @@ -909,21 +727,25 @@ impl ActiveBuild { } } - fn spans(&self, area: Rect) -> Vec { + fn make_spans(&self, area: Rect) -> Vec { let mut spans = Vec::new(); let message = match self.stage { - Stage::Initializing => "initializing... ", - Stage::InstallingWasmTooling => "installing wasm tools... ", - Stage::Compiling => "compiling... ", - Stage::OptimizingWasm => "optimizing wasm... ", - Stage::OptimizingAssets => "optimizing assets... ", - Stage::Finished => "finished! ๐ŸŽ‰ ", + 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); + + let progress = format!(" {}%", (self.progress * 100.0) as u8); if area.width >= self.max_layout_size() { - spans.push(Span::from(message).light_yellow()); + 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()); @@ -958,11 +780,19 @@ fn set_fix_term_hook() { let original_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { _ = disable_raw_mode(); - _ = stdout().execute(LeaveAlternateScreen); + 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 { diff --git a/packages/cli/src/serve/output/render.rs b/packages/cli/src/serve/output/render.rs new file mode 100644 index 000000000..02aa93fe2 --- /dev/null +++ b/packages/cli/src/serve/output/render.rs @@ -0,0 +1,487 @@ +use super::{BuildProgress, TraceMsg, TraceSrc}; +use ansi_to_tui::IntoText as _; +use dioxus_cli_config::Platform; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Style, Stylize}, + text::{Line, Span, Text}, + widgets::{Block, Borders, Clear, List, ListState, Paragraph, Widget, Wrap}, + Frame, +}; +use regex::Regex; +use std::fmt::Write as _; +use std::rc::Rc; +use tracing::Level; + +pub struct TuiLayout { + /// The entire TUI body. + _body: Rc<[Rect]>, + /// The console where build logs are displayed. + console: Rc<[Rect]>, + // The filter drawer if the drawer is open. + filter_drawer: Option>, + + // The border that separates the console and info bars. + border_sep: Rect, + + // The status bar that displays build status, platform, versions, etc. + status_bar: Rc<[Rect]>, + + // Misc + filter_list_state: ListState, +} + +impl TuiLayout { + pub fn new(frame_size: Rect, filter_open: bool) -> Self { + // The full layout + let body = Layout::default() + .direction(Direction::Vertical) + .constraints([ + // Footer Status + Constraint::Length(1), + // Border Separator + Constraint::Length(1), + // Console + Constraint::Fill(1), + // Padding + Constraint::Length(1), + ]) + .split(frame_size); + + let mut console_constraints = vec![Constraint::Fill(1)]; + if filter_open { + console_constraints.push(Constraint::Length(1)); + console_constraints.push(Constraint::Length(25)); + } + + // Build the console, where logs go. + let console = Layout::default() + .direction(Direction::Horizontal) + .constraints(console_constraints) + .split(body[2]); + + let filter_drawer = match filter_open { + false => None, + true => Some( + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(1), + Constraint::Fill(1), + Constraint::Length(1), + ]) + .split(console[2]), + ), + }; + + // Build the status bar. + let status_bar = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Fill(1), Constraint::Fill(1)]) + .split(body[0]); + + // Specify borders + let border_sep_top = body[1]; + + Self { + _body: body, + console, + filter_drawer, + border_sep: border_sep_top, + status_bar, + filter_list_state: ListState::default(), + } + } + + /// Render all decorations. + pub fn render_decor(&self, frame: &mut Frame, filter_open: bool) { + frame.render_widget( + Block::new() + .borders(Borders::TOP) + .border_style(Style::new().white()), + self.border_sep, + ); + + if filter_open { + frame.render_widget( + Block::new() + .borders(Borders::LEFT) + .border_style(Style::new().white()), + self.console[1], + ); + } + } + + /// Render the console and it's logs, returning the number of lines required to render the entire log output. + pub fn render_console( + &self, + frame: &mut Frame, + scroll_position: u16, + messages: &[TraceMsg], + enabled_filters: &[String], + ) -> u16 { + const LEVEL_MAX: usize = "BUILD: ".len(); + + let mut out_text = Text::default(); + + // Assemble the messages + for msg in messages.iter() { + let mut sub_line_padding = 0; + + let text = msg.content.trim_end().into_text().unwrap_or_default(); + + for (idx, line) in text.lines.into_iter().enumerate() { + // Don't add any formatting for cargo messages. + let out_line = if msg.source != TraceSrc::Cargo { + if idx == 0 { + match msg.source { + TraceSrc::Dev => { + let mut spans = vec![Span::from(" DEV: ").light_magenta()]; + + for span in line.spans { + spans.push(span); + } + spans + } + TraceSrc::Build => { + let mut spans = vec![Span::from("BUILD: ").light_blue()]; + + for span in line.spans { + spans.push(span); + } + spans + } + _ => { + // Build level tag: `INFO:`` + // We don't subtract 1 here for `:` because we still want at least 1 padding. + let padding = + build_msg_padding(LEVEL_MAX - msg.level.to_string().len() - 2); + let level = format!("{padding}{}: ", msg.level); + sub_line_padding += level.len(); + + let level_span = Span::from(level); + let level_span = match msg.level { + Level::TRACE => level_span.black(), + Level::DEBUG => level_span.light_magenta(), + Level::INFO => level_span.light_green(), + Level::WARN => level_span.light_yellow(), + Level::ERROR => level_span.light_red(), + }; + + let mut out_line = vec![level_span]; + for span in line.spans { + out_line.push(span); + } + + out_line + } + } + } else { + // Not the first line. Append the padding and merge into list. + let padding = build_msg_padding(sub_line_padding); + + let mut out_line = vec![Span::from(padding)]; + for span in line.spans { + out_line.push(span); + } + out_line + } + } else { + line.spans + }; + + out_text.push_line(Line::from(out_line)); + } + } + + // Only show messages for filters that are enabled. + let mut included_line_ids = Vec::new(); + + for filter in enabled_filters { + let re = Regex::new(filter); + for (index, line) in out_text.lines.iter().enumerate() { + let line_str = line.to_string(); + match re { + Ok(ref re) => { + // sort by provided regex + if re.is_match(&line_str) { + included_line_ids.push(index); + } + } + Err(_) => { + // default to basic string storing + if line_str.contains(filter) { + included_line_ids.push(index); + } + } + } + } + } + + included_line_ids.sort_unstable(); + included_line_ids.dedup(); + + let out_lines = out_text.lines; + let mut out_text = Text::default(); + + if enabled_filters.is_empty() { + for line in out_lines { + out_text.push_line(line.clone()); + } + } else { + for id in included_line_ids { + if let Some(line) = out_lines.get(id) { + out_text.push_line(line.clone()); + } + } + } + + let (console_width, _console_height) = self.get_console_size(); + + let paragraph = Paragraph::new(out_text) + .left_aligned() + .wrap(Wrap { trim: false }); + + let num_lines_wrapping = paragraph.line_count(console_width) as u16; + + paragraph + .scroll((scroll_position, 0)) + .render(self.console[0], frame.buffer_mut()); + + num_lines_wrapping + } + + /// Render the status bar. + pub fn render_status_bar( + &self, + frame: &mut Frame, + _platform: Platform, + build_progress: &BuildProgress, + more_modal_open: bool, + filter_menu_open: bool, + dx_version: &str, + ) { + // left aligned text + let mut spans = vec![ + Span::from("๐Ÿงฌ dx").white(), + Span::from(" ").white(), + Span::from(dx_version).white(), + Span::from(" | ").dark_gray(), + ]; + + // If there is build progress, render the current status. + let is_build_progress = !build_progress.current_builds.is_empty(); + if is_build_progress { + // If the build failed, show a failed status. + // Otherwise, render current status. + let build_failed = build_progress + .current_builds + .values() + .any(|b| b.failed.is_some()); + + if build_failed { + spans.push(Span::from("Build failed โŒ").red()); + } else { + // spans.push(Span::from("status: ").gray()); + let build = build_progress + .current_builds + .values() + .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .unwrap(); + spans.extend_from_slice(&build.make_spans(Rect::new( + 0, + 0, + build.max_layout_size(), + 1, + ))); + } + } + + // right aligned text + let more_span = Span::from("[/] more"); + let more_span = match more_modal_open { + true => more_span.light_yellow(), + false => more_span.gray(), + }; + + let filter_span = Span::from("[f] filter"); + let filter_span = match filter_menu_open { + true => filter_span.light_yellow(), + false => filter_span.gray(), + }; + + // Right-aligned text + let right_line = Line::from(vec![ + Span::from("[o] open").gray(), + Span::from(" | ").gray(), + Span::from("[r] rebuild").gray(), + Span::from(" | ").gray(), + filter_span, + Span::from(" | ").dark_gray(), + more_span, + ]); + + frame.render_widget( + Paragraph::new(Line::from(spans)).left_aligned(), + self.status_bar[0], + ); + + // Render the info + frame.render_widget( + Paragraph::new(right_line).right_aligned(), + self.status_bar[1], + ); + } + + /// Renders the "more" modal to show extra info/keybinds accessible via the more keybind. + pub fn render_more_modal(&self, frame: &mut Frame) { + let modal = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Fill(1), Constraint::Length(5)]) + .split(self.console[0])[1]; + + frame.render_widget(Clear, modal); + frame.render_widget(Block::default().borders(Borders::ALL), modal); + + // Render under construction message + frame.render_widget( + Paragraph::new("Under construction, please check back at a later date!") + .alignment(Alignment::Center), + modal, + ); + } + + /// Render the filter drawer menu. + pub fn render_filter_menu( + &mut self, + frame: &mut Frame, + filters: &[(String, bool)], + selected_filter_index: usize, + search_mode: bool, + search_input: Option<&String>, + ) { + let Some(ref filter_drawer) = self.filter_drawer else { + return; + }; + + // Vertical layout + let container = Layout::default() + .constraints([ + Constraint::Length(4), + Constraint::Fill(1), + Constraint::Length(7), + ]) + .direction(Direction::Vertical) + .split(filter_drawer[1]); + + // Render the search section. + let top_area = Layout::default() + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .direction(Direction::Vertical) + .split(container[0]); + + let search_title = Line::from("Search").gray(); + let search_input_block = Block::new().bg(Color::White); + + let search_text = match search_input { + Some(s) => s, + None => { + if search_mode { + "..." + } else { + "[enter] to type..." + } + } + }; + + let search_input = Paragraph::new(Line::from(search_text)) + .fg(Color::Black) + .block(search_input_block); + + frame.render_widget(search_title, top_area[1]); + frame.render_widget(search_input, top_area[2]); + + // Render the filters + let list_area = container[1]; + let mut list_items = Vec::new(); + + for (filter, enabled) in filters { + let filter = Span::from(filter); + let filter = match enabled { + true => filter.light_yellow(), + false => filter.dark_gray(), + }; + list_items.push(filter); + } + list_items.reverse(); + + let list = List::new(list_items).highlight_symbol("ยป "); + self.filter_list_state.select(Some(selected_filter_index)); + frame.render_stateful_widget(list, list_area, &mut self.filter_list_state); + + // Render the keybind list at the bottom. + let keybinds = container[2]; + let lines = vec![ + Line::from(""), + Line::from("[โ†‘] Up").white(), + Line::from("[โ†“] Down").white(), + Line::from("[โ†] Remove").white(), + Line::from("[โ†’] Toggle").white(), + Line::from("[enter] Type / Submit").white(), + ]; + let text = Text::from(lines); + frame.render_widget(text, keybinds); + } + + /// Returns the height of the console TUI area in number of lines. + pub fn get_console_size(&self) -> (u16, u16) { + (self.console[0].width, self.console[0].height) + } + + /// Render the current scroll position at the top right corner of the frame + pub(crate) fn render_current_scroll( + &self, + scroll_position: u16, + lines: u16, + console_height: u16, + frame: &mut Frame<'_>, + ) { + let mut row = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1)]) + .split(self.console[0])[0]; + + // Hack: shove upwards the text to overlap with the border so text selection doesn't accidentally capture the number + row.y -= 1; + + let max_scroll = lines.saturating_sub(console_height); + if max_scroll == 0 { + return; + } + + let remaining_lines = max_scroll.saturating_sub(scroll_position); + if remaining_lines != 0 { + let text = vec![Span::from(format!(" {remaining_lines}โฌ‡ ")).dark_gray()]; + frame.render_widget( + Paragraph::new(Line::from(text)) + .alignment(Alignment::Right) + .block(Block::default()), + row, + ); + } + } +} + +/// Generate a string with a specified number of spaces. +fn build_msg_padding(padding_len: usize) -> String { + let mut padding = String::new(); + for _ in 0..padding_len { + _ = write!(padding, " "); + } + padding +} diff --git a/packages/cli/src/serve/proxy.rs b/packages/cli/src/serve/proxy.rs index b5a04717f..bb3cb3e57 100644 --- a/packages/cli/src/serve/proxy.rs +++ b/packages/cli/src/serve/proxy.rs @@ -1,3 +1,4 @@ +use crate::TraceSrc; use crate::{Error, Result}; use dioxus_cli_config::WebProxyConfig; @@ -123,7 +124,7 @@ pub(crate) fn proxy_to( } fn handle_proxy_error(e: Error) -> axum::http::Response { - tracing::error!("Proxy error: {}", e); + tracing::error!(dx_src = ?TraceSrc::Dev, "Proxy error: {}", e); axum::http::Response::builder() .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) .body(axum::body::Body::from(format!( diff --git a/packages/cli/src/serve/server.rs b/packages/cli/src/serve/server.rs index ec0c8f338..26d98196e 100644 --- a/packages/cli/src/serve/server.rs +++ b/packages/cli/src/serve/server.rs @@ -1,5 +1,5 @@ -use crate::dioxus_crate::DioxusCrate; use crate::serve::{next_or_pending, Serve}; +use crate::{dioxus_crate::DioxusCrate, TraceSrc}; use crate::{Error, Result}; use axum::extract::{Request, State}; use axum::middleware::{self, Next}; @@ -7,7 +7,7 @@ use axum::{ body::Body, extract::{ ws::{Message, WebSocket}, - Extension, WebSocketUpgrade, + WebSocketUpgrade, }, http::{ header::{HeaderName, HeaderValue, CACHE_CONTROL, EXPIRES, PRAGMA}, @@ -15,7 +15,7 @@ use axum::{ }, response::IntoResponse, routing::{get, get_service}, - Router, + Extension, Router, }; use axum_server::tls_rustls::RustlsConfig; use dioxus_cli_config::{Platform, WebHttpsConfig}; @@ -569,8 +569,12 @@ pub fn get_rustls_with_mkcert(web_config: &WebHttpsConfig) -> Result<(String, St match cmd { Err(e) => { match e.kind() { - io::ErrorKind::NotFound => tracing::error!("mkcert is not installed. See https://github.com/FiloSottile/mkcert#installation for installation instructions."), - e => tracing::error!("an error occurred while generating mkcert certificates: {}", e.to_string()), + io::ErrorKind::NotFound => { + tracing::error!(dx_src = ?TraceSrc::Dev, "`mkcert` is not installed. See https://github.com/FiloSottile/mkcert#installation for installation instructions.") + } + e => { + tracing::error!(dx_src = ?TraceSrc::Dev, "An error occurred while generating mkcert certificates: {}", e.to_string()) + } }; return Err("failed to generate mkcert certificates".into()); } diff --git a/packages/cli/src/serve/watcher.rs b/packages/cli/src/serve/watcher.rs index f6400f479..38581e909 100644 --- a/packages/cli/src/serve/watcher.rs +++ b/packages/cli/src/serve/watcher.rs @@ -3,6 +3,7 @@ use std::{fs, path::PathBuf, time::Duration}; use super::hot_reloading_file_map::HotreloadError; use crate::serve::hot_reloading_file_map::FileMap; +use crate::TraceSrc; use crate::{cli::serve::Serve, dioxus_crate::DioxusCrate}; use dioxus_hot_reload::HotReloadMsg; use dioxus_html::HtmlCtx; @@ -258,7 +259,7 @@ impl Watcher { Err(HotreloadError::Parse) => {} // Otherwise just log the error Err(err) => { - tracing::error!("Error hotreloading file {rust_file:?}: {err}") + tracing::error!(dx_src = ?TraceSrc::Dev, "Error hotreloading file {rust_file:?}: {err}") } } } diff --git a/packages/cli/src/settings.rs b/packages/cli/src/settings.rs index 6ec592984..bc680dfed 100644 --- a/packages/cli/src/settings.rs +++ b/packages/cli/src/settings.rs @@ -6,7 +6,7 @@ use std::{ }; use tracing::{debug, error, warn}; -use crate::CrateConfigError; +use crate::{CrateConfigError, TraceSrc}; const GLOBAL_SETTINGS_FILE_NAME: &str = "dioxus/settings.toml"; @@ -65,7 +65,7 @@ impl CliSettings { /// This does not save to project-level settings. pub fn save(self) -> Result { let path = Self::get_settings_path().ok_or_else(|| { - error!("failed to get settings path"); + error!(dx_src = ?TraceSrc::Dev, "failed to get settings path"); CrateConfigError::Io(Error::new( ErrorKind::NotFound, "failed to get settings path", @@ -73,7 +73,7 @@ impl CliSettings { })?; let data = toml::to_string_pretty(&self).map_err(|e| { - error!(?self, "failed to parse config into toml"); + error!(dx_src = ?TraceSrc::Dev, ?self, "failed to parse config into toml"); CrateConfigError::Io(Error::new(ErrorKind::Other, e.to_string())) })?; @@ -81,6 +81,7 @@ impl CliSettings { let parent_path = path.parent().unwrap(); if let Err(e) = fs::create_dir_all(parent_path) { error!( + dx_src = ?TraceSrc::Dev, ?data, ?path, "failed to create directories for settings file" diff --git a/packages/cli/src/tracer.rs b/packages/cli/src/tracer.rs index afffd42a3..5016d79d9 100644 --- a/packages/cli/src/tracer.rs +++ b/packages/cli/src/tracer.rs @@ -1,39 +1,114 @@ +//! CLI Tracing +//! +//! The CLI's tracing has internal and user-facing logs. User-facing logs are directly routed to the user in some form. +//! Internal logs are stored in a log file for consumption in bug reports and debugging. +//! We use tracing fields to determine whether a log is internal or external and additionally if the log should be +//! formatted or not. +//! +//! These two fields are +//! `dx_src` which tells the logger that this is a user-facing message and should be routed as so. +//! `dx_no_fmt`which tells the logger to avoid formatting the log and to print it as-is. +//! +//! 1. Build general filter +//! 2. Build file append layer for logging to a file. This file is reset on every CLI-run. +//! 3. Build CLI layer for routing tracing logs to the TUI. +//! 4. Build fmt layer for non-interactive logging with a custom writer that prevents output during interactive mode. + +use crate::builder::TargetPlatform; +use console::strip_ansi_codes; use futures_channel::mpsc::{unbounded, UnboundedReceiver, UnboundedSender}; +use std::fmt::Display; use std::{ - env, io, + collections::HashMap, + env, + fmt::{Debug, Write as _}, + fs, + io::{self, Write}, + path::PathBuf, sync::{ atomic::{AtomicBool, Ordering}, Arc, Mutex, }, }; -use tracing_subscriber::{prelude::*, EnvFilter}; +use tracing::Level; +use tracing::{field::Visit, Subscriber}; +use tracing_subscriber::{ + filter::filter_fn, fmt::format, prelude::*, registry::LookupSpan, EnvFilter, Layer, +}; const LOG_ENV: &str = "DIOXUS_LOG"; +const LOG_FILE_NAME: &str = "dx.log"; +const DX_SRC_FLAG: &str = "dx_src"; +const DX_NO_FMT_FLAG: &str = "dx_no_fmt"; + +pub fn log_path() -> PathBuf { + let tmp_dir = std::env::temp_dir(); + tmp_dir.join(LOG_FILE_NAME) +} /// Build tracing infrastructure. pub fn build_tracing() -> CLILogControl { - // If {LOG_ENV} is set, default to env, otherwise filter to cli - // and manganis warnings and errors from other crates let mut filter = EnvFilter::new("error,dx=info,dioxus-cli=info,manganis-cli-support=info"); if env::var(LOG_ENV).is_ok() { filter = EnvFilter::from_env(LOG_ENV); } - // Create writer controller and custom writer. - let (tui_tx, tui_rx) = unbounded(); - let tui_enabled = Arc::new(AtomicBool::new(false)); - - let writer_control = CLILogControl { - tui_rx, - tui_enabled: tui_enabled.clone(), + // Log file + let log_path = log_path(); + _ = std::fs::write(&log_path, ""); + let file_append_layer = match FileAppendLayer::new(log_path) { + Ok(f) => Some(f), + Err(e) => { + tracing::error!(dx_src = ?TraceSrc::Dev, err = ?e, "failed to init log file"); + None + } }; - let cli_writer = Mutex::new(CLIWriter::new(tui_enabled, tui_tx)); - // Build tracing + // Create writer controller and custom writer. + let (output_tx, output_rx) = unbounded(); + let output_enabled = Arc::new(AtomicBool::new(false)); + let writer_control = CLILogControl { + output_rx, + output_enabled: output_enabled.clone(), + }; + + // Build CLI layer + let cli_layer = CLILayer::new(output_enabled.clone(), output_tx); + + // Build fmt layer + let formatter = format::debug_fn(|writer, field, value| { + write!(writer, "{}", format_field(field.name(), value)) + }) + .delimited(" "); + + // Format subscriber + let fmt_writer = Mutex::new(FmtLogWriter::new(output_enabled)); let fmt_layer = tracing_subscriber::fmt::layer() - .with_writer(cli_writer) - .with_filter(filter); - let sub = tracing_subscriber::registry().with(fmt_layer); + .fmt_fields(formatter) + .with_writer(fmt_writer) + .without_time() + .with_filter(filter_fn(|meta| { + // Filter any logs with "dx_no_fmt" or is not user facing (no dx_src) + let mut fields = meta.fields().iter(); + let has_src_flag = fields.any(|f| f.name() == DX_SRC_FLAG); + + if !has_src_flag { + return false; + } + + let has_fmt_flag = fields.any(|f| f.name() == DX_NO_FMT_FLAG); + if has_fmt_flag { + return false; + } + + true + })); + + let sub = tracing_subscriber::registry() + .with(filter) + .with(file_append_layer) + .with(cli_layer) + .with(fmt_layer); #[cfg(feature = "tokio-console")] let sub = sub.with(console_subscriber::spawn()); @@ -43,54 +118,297 @@ pub fn build_tracing() -> CLILogControl { writer_control } -/// Contains the sync primitives to control the CLIWriter. -pub struct CLILogControl { - pub tui_rx: UnboundedReceiver, - pub tui_enabled: Arc, +/// A logging layer that appends to a file. +/// +/// This layer returns on any error allowing the cli to continue work +/// despite failing to log to a file. This helps in case of permission errors and similar. +struct FileAppendLayer { + file_path: PathBuf, + buffer: Mutex, } -/// Represents the CLI's custom tracing writer for conditionally writing logs between outputs. -pub struct CLIWriter { - stdout: io::Stdout, - tui_tx: UnboundedSender, - tui_enabled: Arc, +impl FileAppendLayer { + pub fn new(file_path: PathBuf) -> io::Result { + Ok(Self { + file_path, + buffer: Mutex::new(String::new()), + }) + } } -impl CLIWriter { - /// Create a new CLIWriter with required sync primitives for conditionally routing logs. - pub fn new(tui_enabled: Arc, tui_tx: UnboundedSender) -> Self { - Self { - stdout: io::stdout(), - tui_tx, - tui_enabled, +impl Layer for FileAppendLayer +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + fn on_event( + &self, + event: &tracing::Event<'_>, + _ctx: tracing_subscriber::layer::Context<'_, S>, + ) { + let mut visitor = CollectVisitor::new(); + event.record(&mut visitor); + + let new_line = if visitor.source == TraceSrc::Cargo + || event.fields().any(|f| f.name() == DX_NO_FMT_FLAG) + { + visitor.message + } else { + let meta = event.metadata(); + let level = meta.level(); + + let mut final_msg = String::new(); + _ = write!( + final_msg, + "[{level}] {}: {} ", + meta.module_path().unwrap_or("dx"), + visitor.message + ); + + for (field, value) in visitor.fields.iter() { + _ = write!(final_msg, "{} ", format_field(field, value)); + } + _ = writeln!(final_msg); + final_msg + }; + + // Append logs + let new_data = strip_ansi_codes(&new_line).to_string(); + + if let Ok(mut buf) = self.buffer.lock() { + *buf += &new_data; + // TODO: Make this efficient. + _ = fs::write(&self.file_path, buf.as_bytes()); } } } -// Implement a conditional writer so that logs are routed to the appropriate place. -impl io::Write for CLIWriter { +/// This is our "subscriber" (layer) that records structured data for the tui output. +struct CLILayer { + internal_output_enabled: Arc, + output_tx: UnboundedSender, +} + +impl CLILayer { + pub fn new( + internal_output_enabled: Arc, + output_tx: UnboundedSender, + ) -> Self { + Self { + internal_output_enabled, + output_tx, + } + } +} + +impl Layer for CLILayer +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + // Subscribe to user + fn on_event( + &self, + event: &tracing::Event<'_>, + _ctx: tracing_subscriber::layer::Context<'_, S>, + ) { + // We only care about user-facing logs. + let has_src_flag = event.fields().any(|f| f.name() == DX_SRC_FLAG); + if !has_src_flag { + return; + } + + let mut visitor = CollectVisitor::new(); + event.record(&mut visitor); + + // If the TUI output is disabled we let fmt subscriber handle the logs + // EXCEPT for cargo logs which we just print. + if !self.internal_output_enabled.load(Ordering::SeqCst) { + if visitor.source == TraceSrc::Cargo + || event.fields().any(|f| f.name() == DX_NO_FMT_FLAG) + { + println!("{}", visitor.message); + } + return; + } + + let meta = event.metadata(); + let level = meta.level(); + + let mut final_msg = String::new(); + write!(final_msg, "{} ", visitor.message).unwrap(); + + for (field, value) in visitor.fields.iter() { + write!(final_msg, "{} ", format_field(field, value)).unwrap(); + } + + if visitor.source == TraceSrc::Unknown { + visitor.source = TraceSrc::Dev; + } + + self.output_tx + .unbounded_send(TraceMsg::new(visitor.source, *level, final_msg)) + .unwrap(); + } + + // TODO: support spans? structured tui log display? +} + +/// A record visitor that collects dx-specific info and user-provided fields for logging consumption. +struct CollectVisitor { + message: String, + source: TraceSrc, + dx_user_msg: bool, + fields: HashMap, +} + +impl CollectVisitor { + pub fn new() -> Self { + Self { + message: String::new(), + source: TraceSrc::Unknown, + dx_user_msg: false, + fields: HashMap::new(), + } + } +} + +impl Visit for CollectVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + let name = field.name(); + + let mut value_string = String::new(); + write!(value_string, "{:?}", value).unwrap(); + + if name == "message" { + self.message = value_string; + return; + } + + if name == DX_SRC_FLAG { + self.source = TraceSrc::from(value_string); + self.dx_user_msg = true; + return; + } + + self.fields.insert(name.to_string(), value_string); + } +} + +// Contains the sync primitives to control the CLIWriter. +pub struct CLILogControl { + pub output_rx: UnboundedReceiver, + pub output_enabled: Arc, +} + +struct FmtLogWriter { + stdout: io::Stdout, + output_enabled: Arc, +} + +impl FmtLogWriter { + pub fn new(output_enabled: Arc) -> Self { + Self { + stdout: io::stdout(), + output_enabled, + } + } +} + +impl Write for FmtLogWriter { fn write(&mut self, buf: &[u8]) -> io::Result { - if self.tui_enabled.load(Ordering::SeqCst) { - let len = buf.len(); - - let as_string = String::from_utf8(buf.to_vec()) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - - self.tui_tx - .unbounded_send(as_string) - .map_err(|e| io::Error::new(io::ErrorKind::BrokenPipe, e))?; - - Ok(len) - } else { + // Handle selection between TUI or Terminal output. + if !self.output_enabled.load(Ordering::SeqCst) { self.stdout.write(buf) + } else { + Ok(buf.len()) } } fn flush(&mut self) -> io::Result<()> { - if !self.tui_enabled.load(Ordering::SeqCst) { + if !self.output_enabled.load(Ordering::SeqCst) { self.stdout.flush() } else { Ok(()) } } } + +/// Formats a tracing field and value, removing any internal fields from the final output. +fn format_field(field_name: &str, value: &dyn Debug) -> String { + let mut out = String::new(); + match field_name { + DX_SRC_FLAG => write!(out, ""), + DX_NO_FMT_FLAG => write!(out, ""), + "message" => write!(out, "{:?}", value), + _ => write!(out, "{}={:?}", field_name, value), + } + .unwrap(); + + out +} + +#[derive(Clone, PartialEq)] +pub struct TraceMsg { + pub source: TraceSrc, + pub level: Level, + pub content: String, +} + +impl TraceMsg { + pub fn new(source: TraceSrc, level: Level, content: String) -> Self { + Self { + source, + level, + content, + } + } +} + +#[derive(Clone, PartialEq)] +pub enum TraceSrc { + App(TargetPlatform), + Dev, + Build, + /// Provides no formatting. + Cargo, + /// Avoid using this + Unknown, +} + +impl std::fmt::Debug for TraceSrc { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let as_string = self.to_string(); + write!(f, "{as_string}") + } +} + +impl From for TraceSrc { + fn from(value: String) -> Self { + match value.as_str() { + "dev" => Self::Dev, + "build" => Self::Build, + "cargo" => Self::Cargo, + "web" => Self::App(TargetPlatform::Web), + "desktop" => Self::App(TargetPlatform::Desktop), + "server" => Self::App(TargetPlatform::Server), + "liveview" => Self::App(TargetPlatform::Liveview), + _ => Self::Unknown, + } + } +} + +impl Display for TraceSrc { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::App(platform) => match platform { + TargetPlatform::Web => write!(f, "web"), + TargetPlatform::Desktop => write!(f, "desktop"), + TargetPlatform::Server => write!(f, "server"), + TargetPlatform::Liveview => write!(f, "server"), + }, + Self::Dev => write!(f, "dev"), + Self::Build => write!(f, "build"), + Self::Cargo => write!(f, "cargo"), + Self::Unknown => write!(f, "n/a"), + } + } +} diff --git a/packages/web/src/hot_reload.rs b/packages/web/src/hot_reload.rs index a437d336d..b0cdb0b91 100644 --- a/packages/web/src/hot_reload.rs +++ b/packages/web/src/hot_reload.rs @@ -232,7 +232,8 @@ pub(crate) fn invalidate_browser_asset_cache() { for x in 0..links.length() { use wasm_bindgen::JsCast; let link: web_sys::Element = links.get(x).unwrap().unchecked_into(); - let href = link.get_attribute("href").unwrap(); - _ = link.set_attribute("href", &format!("{}?{}", href, noise)); + if let Some(href) = link.get_attribute("href") { + _ = link.set_attribute("href", &format!("{}?{}", href, noise)); + } } }