use std::collections::{HashMap, HashSet}; 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_devtools::HotReloadMsg; use dioxus_html::HtmlCtx; use futures_channel::mpsc::{UnboundedReceiver, UnboundedSender}; use futures_util::StreamExt; use ignore::gitignore::Gitignore; use notify::{ event::{MetadataKind, ModifyKind}, Config, EventKind, }; /// This struct stores the file watcher and the filemap for the project. /// /// This is where we do workspace discovery and recursively listen for changes in Rust files and asset /// directories. pub struct Watcher { _tx: UnboundedSender, rx: UnboundedReceiver, _last_update_time: i64, _watcher: Box, queued_events: Vec, file_map: FileMap, ignore: Gitignore, applied_hot_reload_message: Option, } impl Watcher { pub fn start(serve: &Serve, config: &DioxusCrate) -> Self { let (tx, rx) = futures_channel::mpsc::unbounded(); // Extend the watch path to include: // - the assets directory - this is so we can hotreload CSS and other assets by default // - the Cargo.toml file - this is so we can hotreload the project if the user changes dependencies // - the Dioxus.toml file - this is so we can hotreload the project if the user changes the Dioxus config let mut allow_watch_path = config.dioxus_config.web.watcher.watch_path.clone(); allow_watch_path.push(config.dioxus_config.application.asset_dir.clone()); allow_watch_path.push("Cargo.toml".to_string().into()); allow_watch_path.push("Dioxus.toml".to_string().into()); allow_watch_path.dedup(); let crate_dir = config.crate_dir(); let mut builder = ignore::gitignore::GitignoreBuilder::new(&crate_dir); builder.add(crate_dir.join(".gitignore")); let out_dir = config.out_dir(); let out_dir_str = out_dir.display().to_string(); let excluded_paths = vec![ ".git", ".github", ".vscode", "target", "node_modules", "dist", &out_dir_str, ]; for path in excluded_paths { builder .add_line(None, path) .expect("failed to add path to file excluder"); } let ignore = builder.build().unwrap(); // Build the event handler for notify. let notify_event_handler = { let tx = tx.clone(); move |info: notify::Result| { if let Ok(e) = info { if is_allowed_notify_event(&e) { _ = tx.unbounded_send(e); } } } }; // If we are in WSL, we must use Notify's poll watcher due to an event propagation issue. let is_wsl = is_wsl(); const NOTIFY_ERROR_MSG: &str = "Failed to create file watcher.\nEnsure you have the required permissions to watch the specified directories."; // Create the file watcher. let mut watcher: Box = match is_wsl { true => { let poll_interval = Duration::from_secs( serve.server_arguments.wsl_file_poll_interval.unwrap_or(2) as u64, ); Box::new( notify::PollWatcher::new( notify_event_handler, Config::default().with_poll_interval(poll_interval), ) .expect(NOTIFY_ERROR_MSG), ) } false => { Box::new(notify::recommended_watcher(notify_event_handler).expect(NOTIFY_ERROR_MSG)) } }; // Watch the specified paths // todo: make sure we don't double-watch paths if they're nested for sub_path in allow_watch_path { let path = &config.crate_dir().join(sub_path); // If the path is ignored, don't watch it if ignore.matched(path, path.is_dir()).is_ignore() { continue; } let mode = notify::RecursiveMode::Recursive; if let Err(err) = watcher.watch(path, mode) { tracing::warn!("Failed to watch path: {}", err); } } // Probe the entire project looking for our rsx calls // Whenever we get an update from the file watcher, we'll try to hotreload against this file map let file_map = FileMap::create_with_filter::(config.crate_dir(), |path| { ignore.matched(path, path.is_dir()).is_ignore() }) .unwrap(); Self { _tx: tx, rx, _watcher: watcher, file_map, ignore, queued_events: Vec::new(), _last_update_time: chrono::Local::now().timestamp(), applied_hot_reload_message: None, } } /// A cancel safe handle to the file watcher /// /// todo: this should be simpler logic? pub async fn wait(&mut self) { // Pull off any queued events in succession while let Ok(Some(event)) = self.rx.try_next() { self.queued_events.push(event); } if !self.queued_events.is_empty() { return; } // If there are no queued events, wait for the next event if let Some(event) = self.rx.next().await { self.queued_events.push(event); } } /// Deques changed files from the event queue, doing the proper intelligent filtering pub fn dequeue_changed_files(&mut self, config: &DioxusCrate) -> Vec { let mut all_mods: Vec = vec![]; // Decompose the events into a list of all the files that have changed for event in self.queued_events.drain(..) { // We only care about certain events. if !is_allowed_notify_event(&event) { continue; } for path in event.paths { all_mods.push(path.clone()); } } let mut modified_files = vec![]; // For the non-rust files, we want to check if it's an asset file // This would mean the asset lives somewhere under the /assets directory or is referenced by magnanis in the linker // todo: mg integration here let _asset_dir = config .dioxus_config .application .asset_dir .canonicalize() .ok(); for path in all_mods.iter() { if path.extension().is_none() { continue; } // Workaround for notify and vscode-like editor: // when edit & save a file in vscode, there will be two notifications, // the first one is a file with empty content. // filter the empty file notification to avoid false rebuild during hot-reload if let Ok(metadata) = std::fs::metadata(path) { if metadata.len() == 0 { continue; } } // If the extension is a backup file, or a hidden file, ignore it completely (no rebuilds) if is_backup_file(path.to_path_buf()) { tracing::trace!("Ignoring backup file: {:?}", path); continue; } // If the path is ignored, don't watch it if self.ignore.matched(path, path.is_dir()).is_ignore() { continue; } modified_files.push(path.clone()); } modified_files } pub fn attempt_hot_reload( &mut self, config: &DioxusCrate, modified_files: Vec, ) -> Option { // If we have any changes to the rust files, we need to update the file map let crate_dir = config.crate_dir(); let mut templates = vec![]; // Prepare the hotreload message we need to send let mut edited_rust_files = Vec::new(); let mut assets = Vec::new(); let mut unknown_files = Vec::new(); for path in modified_files { // for various assets that might be linked in, we just try to hotreloading them forcefully // That is, unless they appear in an include! macro, in which case we need to a full rebuild.... let Some(ext) = path.extension().and_then(|v| v.to_str()) else { continue; }; match ext { "rs" => edited_rust_files.push(path), _ if path.starts_with("assets") => assets.push(path), _ => unknown_files.push(path), } } for rust_file in edited_rust_files { match self.file_map.update_rsx::(&rust_file, &crate_dir) { Ok(hotreloaded_templates) => { templates.extend(hotreloaded_templates); } // If the file is not reloadable, we need to rebuild Err(HotreloadError::Notreloadable) => return None, // The rust file may have failed to parse, but that is most likely // because the user is in the middle of adding new code // We just ignore the error and let Rust analyzer warn about the problem Err(HotreloadError::Parse) => {} // Otherwise just log the error Err(err) => { tracing::error!(dx_src = ?TraceSrc::Dev, "Error hotreloading file {rust_file:?}: {err}") } } } let msg = HotReloadMsg { templates, assets, unknown_files, }; self.add_hot_reload_message(&msg); Some(msg) } /// Get any hot reload changes that have been applied since the last full rebuild pub fn applied_hot_reload_changes(&mut self) -> Option { self.applied_hot_reload_message.clone() } /// Clear the hot reload changes. This should be called any time a new build is starting pub fn clear_hot_reload_changes(&mut self) { self.applied_hot_reload_message.take(); } /// Store the hot reload changes for any future clients that connect fn add_hot_reload_message(&mut self, msg: &HotReloadMsg) { match &mut self.applied_hot_reload_message { Some(applied) => { // Merge the assets, unknown files, and templates // We keep the newer change if there is both a old and new change let mut templates: HashMap = std::mem::take(&mut applied.templates) .into_iter() .map(|template| (template.location.clone(), template)) .collect(); let mut assets: HashSet = std::mem::take(&mut applied.assets).into_iter().collect(); let mut unknown_files: HashSet = std::mem::take(&mut applied.unknown_files) .into_iter() .collect(); for template in &msg.templates { templates.insert(template.location.clone(), template.clone()); } assets.extend(msg.assets.iter().cloned()); unknown_files.extend(msg.unknown_files.iter().cloned()); applied.templates = templates.into_values().collect(); applied.assets = assets.into_iter().collect(); applied.unknown_files = unknown_files.into_iter().collect(); } None => { self.applied_hot_reload_message = Some(msg.clone()); } } } /// Ensure the changes we've received from the queue are actually legit changes to either assets or /// rust code. We don't care about changes otherwise, unless we get a signal elsewhere to do a full rebuild pub fn pending_changes(&mut self) -> bool { !self.queued_events.is_empty() } } fn is_backup_file(path: PathBuf) -> bool { // If there's a tilde at the end of the file, it's a backup file if let Some(name) = path.file_name() { if let Some(name) = name.to_str() { if name.ends_with('~') { return true; } } } // if the file is hidden, it's a backup file if let Some(name) = path.file_name() { if let Some(name) = name.to_str() { if name.starts_with('.') { return true; } } } false } /// Tests if the provided [`notify::Event`] is something we listen to so we can avoid unescessary hot reloads. fn is_allowed_notify_event(event: ¬ify::Event) -> bool { match event.kind { EventKind::Modify(ModifyKind::Data(_)) => true, EventKind::Modify(ModifyKind::Name(_)) => true, EventKind::Create(_) => true, EventKind::Remove(_) => true, // The primary modification event on WSL's poll watcher. EventKind::Modify(ModifyKind::Metadata(MetadataKind::WriteTime)) => true, // Catch-all for unknown event types. EventKind::Modify(ModifyKind::Any) => true, // Don't care about anything else. _ => false, } } const WSL_1: &str = "/proc/sys/kernel/osrelease"; const WSL_2: &str = "/proc/version"; const WSL_KEYWORDS: [&str; 2] = ["microsoft", "wsl"]; /// Detects if `dx` is being ran in a WSL environment. /// /// We determine this based on whether the keyword `microsoft` or `wsl` is contained within the [`WSL_1`] or [`WSL_2`] files. /// This may fail in the future as it isn't guaranteed by Microsoft. /// See https://github.com/microsoft/WSL/issues/423#issuecomment-221627364 fn is_wsl() -> bool { // Test 1st File if let Ok(content) = fs::read_to_string(WSL_1) { let lowercase = content.to_lowercase(); for keyword in WSL_KEYWORDS { if lowercase.contains(keyword) { return true; } } } // Test 2nd File if let Ok(content) = fs::read_to_string(WSL_2) { let lowercase = content.to_lowercase(); for keyword in WSL_KEYWORDS { if lowercase.contains(keyword) { return true; } } } false } #[test] fn test_is_backup_file() { assert!(is_backup_file(PathBuf::from("examples/test.rs~"))); assert!(is_backup_file(PathBuf::from("examples/.back"))); assert!(is_backup_file(PathBuf::from("test.rs~"))); assert!(is_backup_file(PathBuf::from(".back"))); assert!(!is_backup_file(PathBuf::from("val.rs"))); assert!(!is_backup_file(PathBuf::from( "/Users/jonkelley/Development/Tinkering/basic_05_example/src/lib.rs" ))); assert!(!is_backup_file(PathBuf::from("exmaples/val.rs"))); }