123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409 |
- 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<notify::Event>,
- rx: UnboundedReceiver<notify::Event>,
- _last_update_time: i64,
- _watcher: Box<dyn notify::Watcher>,
- queued_events: Vec<notify::Event>,
- file_map: FileMap,
- ignore: Gitignore,
- applied_hot_reload_message: Option<HotReloadMsg>,
- }
- 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<notify::Event>| {
- 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<dyn notify::Watcher> = 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::<HtmlCtx>(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<PathBuf> {
- let mut all_mods: Vec<PathBuf> = 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<PathBuf>,
- ) -> Option<HotReloadMsg> {
- // 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::<HtmlCtx>(&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<HotReloadMsg> {
- 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<String, _> = std::mem::take(&mut applied.templates)
- .into_iter()
- .map(|template| (template.location.clone(), template))
- .collect();
- let mut assets: HashSet<PathBuf> =
- std::mem::take(&mut applied.assets).into_iter().collect();
- let mut unknown_files: HashSet<PathBuf> =
- 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")));
- }
|