watcher.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. use std::collections::{HashMap, HashSet};
  2. use std::{fs, path::PathBuf, time::Duration};
  3. use super::hot_reloading_file_map::HotreloadError;
  4. use crate::serve::hot_reloading_file_map::FileMap;
  5. use crate::TraceSrc;
  6. use crate::{cli::serve::Serve, dioxus_crate::DioxusCrate};
  7. use dioxus_devtools::HotReloadMsg;
  8. use dioxus_html::HtmlCtx;
  9. use futures_channel::mpsc::{UnboundedReceiver, UnboundedSender};
  10. use futures_util::StreamExt;
  11. use ignore::gitignore::Gitignore;
  12. use notify::{
  13. event::{MetadataKind, ModifyKind},
  14. Config, EventKind,
  15. };
  16. /// This struct stores the file watcher and the filemap for the project.
  17. ///
  18. /// This is where we do workspace discovery and recursively listen for changes in Rust files and asset
  19. /// directories.
  20. pub struct Watcher {
  21. _tx: UnboundedSender<notify::Event>,
  22. rx: UnboundedReceiver<notify::Event>,
  23. _last_update_time: i64,
  24. _watcher: Box<dyn notify::Watcher>,
  25. queued_events: Vec<notify::Event>,
  26. file_map: FileMap,
  27. ignore: Gitignore,
  28. applied_hot_reload_message: Option<HotReloadMsg>,
  29. }
  30. impl Watcher {
  31. pub fn start(serve: &Serve, config: &DioxusCrate) -> Self {
  32. let (tx, rx) = futures_channel::mpsc::unbounded();
  33. // Extend the watch path to include:
  34. // - the assets directory - this is so we can hotreload CSS and other assets by default
  35. // - the Cargo.toml file - this is so we can hotreload the project if the user changes dependencies
  36. // - the Dioxus.toml file - this is so we can hotreload the project if the user changes the Dioxus config
  37. let mut allow_watch_path = config.dioxus_config.web.watcher.watch_path.clone();
  38. allow_watch_path.push(config.dioxus_config.application.asset_dir.clone());
  39. allow_watch_path.push("Cargo.toml".to_string().into());
  40. allow_watch_path.push("Dioxus.toml".to_string().into());
  41. allow_watch_path.dedup();
  42. let crate_dir = config.crate_dir();
  43. let mut builder = ignore::gitignore::GitignoreBuilder::new(&crate_dir);
  44. builder.add(crate_dir.join(".gitignore"));
  45. let out_dir = config.out_dir();
  46. let out_dir_str = out_dir.display().to_string();
  47. let excluded_paths = vec![
  48. ".git",
  49. ".github",
  50. ".vscode",
  51. "target",
  52. "node_modules",
  53. "dist",
  54. &out_dir_str,
  55. ];
  56. for path in excluded_paths {
  57. builder
  58. .add_line(None, path)
  59. .expect("failed to add path to file excluder");
  60. }
  61. let ignore = builder.build().unwrap();
  62. // Build the event handler for notify.
  63. let notify_event_handler = {
  64. let tx = tx.clone();
  65. move |info: notify::Result<notify::Event>| {
  66. if let Ok(e) = info {
  67. if is_allowed_notify_event(&e) {
  68. _ = tx.unbounded_send(e);
  69. }
  70. }
  71. }
  72. };
  73. // If we are in WSL, we must use Notify's poll watcher due to an event propagation issue.
  74. let is_wsl = is_wsl();
  75. const NOTIFY_ERROR_MSG: &str = "Failed to create file watcher.\nEnsure you have the required permissions to watch the specified directories.";
  76. // Create the file watcher.
  77. let mut watcher: Box<dyn notify::Watcher> = match is_wsl {
  78. true => {
  79. let poll_interval = Duration::from_secs(
  80. serve.server_arguments.wsl_file_poll_interval.unwrap_or(2) as u64,
  81. );
  82. Box::new(
  83. notify::PollWatcher::new(
  84. notify_event_handler,
  85. Config::default().with_poll_interval(poll_interval),
  86. )
  87. .expect(NOTIFY_ERROR_MSG),
  88. )
  89. }
  90. false => {
  91. Box::new(notify::recommended_watcher(notify_event_handler).expect(NOTIFY_ERROR_MSG))
  92. }
  93. };
  94. // Watch the specified paths
  95. // todo: make sure we don't double-watch paths if they're nested
  96. for sub_path in allow_watch_path {
  97. let path = &config.crate_dir().join(sub_path);
  98. // If the path is ignored, don't watch it
  99. if ignore.matched(path, path.is_dir()).is_ignore() {
  100. continue;
  101. }
  102. let mode = notify::RecursiveMode::Recursive;
  103. if let Err(err) = watcher.watch(path, mode) {
  104. tracing::warn!("Failed to watch path: {}", err);
  105. }
  106. }
  107. // Probe the entire project looking for our rsx calls
  108. // Whenever we get an update from the file watcher, we'll try to hotreload against this file map
  109. let file_map = FileMap::create_with_filter::<HtmlCtx>(config.crate_dir(), |path| {
  110. ignore.matched(path, path.is_dir()).is_ignore()
  111. })
  112. .unwrap();
  113. Self {
  114. _tx: tx,
  115. rx,
  116. _watcher: watcher,
  117. file_map,
  118. ignore,
  119. queued_events: Vec::new(),
  120. _last_update_time: chrono::Local::now().timestamp(),
  121. applied_hot_reload_message: None,
  122. }
  123. }
  124. /// A cancel safe handle to the file watcher
  125. ///
  126. /// todo: this should be simpler logic?
  127. pub async fn wait(&mut self) {
  128. // Pull off any queued events in succession
  129. while let Ok(Some(event)) = self.rx.try_next() {
  130. self.queued_events.push(event);
  131. }
  132. if !self.queued_events.is_empty() {
  133. return;
  134. }
  135. // If there are no queued events, wait for the next event
  136. if let Some(event) = self.rx.next().await {
  137. self.queued_events.push(event);
  138. }
  139. }
  140. /// Deques changed files from the event queue, doing the proper intelligent filtering
  141. pub fn dequeue_changed_files(&mut self, config: &DioxusCrate) -> Vec<PathBuf> {
  142. let mut all_mods: Vec<PathBuf> = vec![];
  143. // Decompose the events into a list of all the files that have changed
  144. for event in self.queued_events.drain(..) {
  145. // We only care about certain events.
  146. if !is_allowed_notify_event(&event) {
  147. continue;
  148. }
  149. for path in event.paths {
  150. all_mods.push(path.clone());
  151. }
  152. }
  153. let mut modified_files = vec![];
  154. // For the non-rust files, we want to check if it's an asset file
  155. // This would mean the asset lives somewhere under the /assets directory or is referenced by magnanis in the linker
  156. // todo: mg integration here
  157. let _asset_dir = config
  158. .dioxus_config
  159. .application
  160. .asset_dir
  161. .canonicalize()
  162. .ok();
  163. for path in all_mods.iter() {
  164. if path.extension().is_none() {
  165. continue;
  166. }
  167. // Workaround for notify and vscode-like editor:
  168. // when edit & save a file in vscode, there will be two notifications,
  169. // the first one is a file with empty content.
  170. // filter the empty file notification to avoid false rebuild during hot-reload
  171. if let Ok(metadata) = std::fs::metadata(path) {
  172. if metadata.len() == 0 {
  173. continue;
  174. }
  175. }
  176. // If the extension is a backup file, or a hidden file, ignore it completely (no rebuilds)
  177. if is_backup_file(path.to_path_buf()) {
  178. tracing::trace!("Ignoring backup file: {:?}", path);
  179. continue;
  180. }
  181. // If the path is ignored, don't watch it
  182. if self.ignore.matched(path, path.is_dir()).is_ignore() {
  183. continue;
  184. }
  185. modified_files.push(path.clone());
  186. }
  187. modified_files
  188. }
  189. pub fn attempt_hot_reload(
  190. &mut self,
  191. config: &DioxusCrate,
  192. modified_files: Vec<PathBuf>,
  193. ) -> Option<HotReloadMsg> {
  194. // If we have any changes to the rust files, we need to update the file map
  195. let crate_dir = config.crate_dir();
  196. let mut templates = vec![];
  197. // Prepare the hotreload message we need to send
  198. let mut edited_rust_files = Vec::new();
  199. let mut assets = Vec::new();
  200. let mut unknown_files = Vec::new();
  201. for path in modified_files {
  202. // for various assets that might be linked in, we just try to hotreloading them forcefully
  203. // That is, unless they appear in an include! macro, in which case we need to a full rebuild....
  204. let Some(ext) = path.extension().and_then(|v| v.to_str()) else {
  205. continue;
  206. };
  207. match ext {
  208. "rs" => edited_rust_files.push(path),
  209. _ if path.starts_with("assets") => assets.push(path),
  210. _ => unknown_files.push(path),
  211. }
  212. }
  213. for rust_file in edited_rust_files {
  214. match self.file_map.update_rsx::<HtmlCtx>(&rust_file, &crate_dir) {
  215. Ok(hotreloaded_templates) => {
  216. templates.extend(hotreloaded_templates);
  217. }
  218. // If the file is not reloadable, we need to rebuild
  219. Err(HotreloadError::Notreloadable) => return None,
  220. // The rust file may have failed to parse, but that is most likely
  221. // because the user is in the middle of adding new code
  222. // We just ignore the error and let Rust analyzer warn about the problem
  223. Err(HotreloadError::Parse) => {}
  224. // Otherwise just log the error
  225. Err(err) => {
  226. tracing::error!(dx_src = ?TraceSrc::Dev, "Error hotreloading file {rust_file:?}: {err}")
  227. }
  228. }
  229. }
  230. let msg = HotReloadMsg {
  231. templates,
  232. assets,
  233. unknown_files,
  234. };
  235. self.add_hot_reload_message(&msg);
  236. Some(msg)
  237. }
  238. /// Get any hot reload changes that have been applied since the last full rebuild
  239. pub fn applied_hot_reload_changes(&mut self) -> Option<HotReloadMsg> {
  240. self.applied_hot_reload_message.clone()
  241. }
  242. /// Clear the hot reload changes. This should be called any time a new build is starting
  243. pub fn clear_hot_reload_changes(&mut self) {
  244. self.applied_hot_reload_message.take();
  245. }
  246. /// Store the hot reload changes for any future clients that connect
  247. fn add_hot_reload_message(&mut self, msg: &HotReloadMsg) {
  248. match &mut self.applied_hot_reload_message {
  249. Some(applied) => {
  250. // Merge the assets, unknown files, and templates
  251. // We keep the newer change if there is both a old and new change
  252. let mut templates: HashMap<String, _> = std::mem::take(&mut applied.templates)
  253. .into_iter()
  254. .map(|template| (template.location.clone(), template))
  255. .collect();
  256. let mut assets: HashSet<PathBuf> =
  257. std::mem::take(&mut applied.assets).into_iter().collect();
  258. let mut unknown_files: HashSet<PathBuf> =
  259. std::mem::take(&mut applied.unknown_files)
  260. .into_iter()
  261. .collect();
  262. for template in &msg.templates {
  263. templates.insert(template.location.clone(), template.clone());
  264. }
  265. assets.extend(msg.assets.iter().cloned());
  266. unknown_files.extend(msg.unknown_files.iter().cloned());
  267. applied.templates = templates.into_values().collect();
  268. applied.assets = assets.into_iter().collect();
  269. applied.unknown_files = unknown_files.into_iter().collect();
  270. }
  271. None => {
  272. self.applied_hot_reload_message = Some(msg.clone());
  273. }
  274. }
  275. }
  276. /// Ensure the changes we've received from the queue are actually legit changes to either assets or
  277. /// rust code. We don't care about changes otherwise, unless we get a signal elsewhere to do a full rebuild
  278. pub fn pending_changes(&mut self) -> bool {
  279. !self.queued_events.is_empty()
  280. }
  281. }
  282. fn is_backup_file(path: PathBuf) -> bool {
  283. // If there's a tilde at the end of the file, it's a backup file
  284. if let Some(name) = path.file_name() {
  285. if let Some(name) = name.to_str() {
  286. if name.ends_with('~') {
  287. return true;
  288. }
  289. }
  290. }
  291. // if the file is hidden, it's a backup file
  292. if let Some(name) = path.file_name() {
  293. if let Some(name) = name.to_str() {
  294. if name.starts_with('.') {
  295. return true;
  296. }
  297. }
  298. }
  299. false
  300. }
  301. /// Tests if the provided [`notify::Event`] is something we listen to so we can avoid unescessary hot reloads.
  302. fn is_allowed_notify_event(event: &notify::Event) -> bool {
  303. match event.kind {
  304. EventKind::Modify(ModifyKind::Data(_)) => true,
  305. EventKind::Modify(ModifyKind::Name(_)) => true,
  306. EventKind::Create(_) => true,
  307. EventKind::Remove(_) => true,
  308. // The primary modification event on WSL's poll watcher.
  309. EventKind::Modify(ModifyKind::Metadata(MetadataKind::WriteTime)) => true,
  310. // Catch-all for unknown event types.
  311. EventKind::Modify(ModifyKind::Any) => true,
  312. // Don't care about anything else.
  313. _ => false,
  314. }
  315. }
  316. const WSL_1: &str = "/proc/sys/kernel/osrelease";
  317. const WSL_2: &str = "/proc/version";
  318. const WSL_KEYWORDS: [&str; 2] = ["microsoft", "wsl"];
  319. /// Detects if `dx` is being ran in a WSL environment.
  320. ///
  321. /// We determine this based on whether the keyword `microsoft` or `wsl` is contained within the [`WSL_1`] or [`WSL_2`] files.
  322. /// This may fail in the future as it isn't guaranteed by Microsoft.
  323. /// See https://github.com/microsoft/WSL/issues/423#issuecomment-221627364
  324. fn is_wsl() -> bool {
  325. // Test 1st File
  326. if let Ok(content) = fs::read_to_string(WSL_1) {
  327. let lowercase = content.to_lowercase();
  328. for keyword in WSL_KEYWORDS {
  329. if lowercase.contains(keyword) {
  330. return true;
  331. }
  332. }
  333. }
  334. // Test 2nd File
  335. if let Ok(content) = fs::read_to_string(WSL_2) {
  336. let lowercase = content.to_lowercase();
  337. for keyword in WSL_KEYWORDS {
  338. if lowercase.contains(keyword) {
  339. return true;
  340. }
  341. }
  342. }
  343. false
  344. }
  345. #[test]
  346. fn test_is_backup_file() {
  347. assert!(is_backup_file(PathBuf::from("examples/test.rs~")));
  348. assert!(is_backup_file(PathBuf::from("examples/.back")));
  349. assert!(is_backup_file(PathBuf::from("test.rs~")));
  350. assert!(is_backup_file(PathBuf::from(".back")));
  351. assert!(!is_backup_file(PathBuf::from("val.rs")));
  352. assert!(!is_backup_file(PathBuf::from(
  353. "/Users/jonkelley/Development/Tinkering/basic_05_example/src/lib.rs"
  354. )));
  355. assert!(!is_backup_file(PathBuf::from("exmaples/val.rs")));
  356. }