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)); + } } }