diff --git a/Cargo.lock b/Cargo.lock index fea2061..bf2fe08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1248,6 +1248,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.31" @@ -1821,6 +1830,26 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "inotify" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" +dependencies = [ + "bitflags 2.11.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea94e891b3606826e9c998be69ddca42247dad8ad50b1649a5cb7e1c9ae06fd" +dependencies = [ + "libc", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -1964,6 +1993,26 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.11.0", + "libc", +] + [[package]] name = "kurbo" version = "0.10.4" @@ -2407,6 +2456,33 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2954,6 +3030,7 @@ dependencies = [ "iced", "image", "log", + "notify", "otty-libterm", "otty-ui-term", "otty-ui-tree", diff --git a/otty/Cargo.toml b/otty/Cargo.toml index b7c2842..821b23c 100644 --- a/otty/Cargo.toml +++ b/otty/Cargo.toml @@ -22,6 +22,7 @@ thiserror = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } shell-words = "1.1.0" +notify = "8.2.0" [package.metadata.generate-rpm] maintainer = "Ilya Shvyryalkin " diff --git a/otty/src/events/explorer.rs b/otty/src/events/explorer.rs index 2af3979..e9af9be 100644 --- a/otty/src/events/explorer.rs +++ b/otty/src/events/explorer.rs @@ -52,6 +52,14 @@ fn handle_effect(app: &mut App, effect: ExplorerEffect) -> Task { } }) }, + ExplorerEffect::ReloadDirectoryRequested { directory } => { + load_directory_async(directory.clone(), move |nodes| { + ExplorerIntent::DirectoryReloaded { + directory: directory.clone(), + nodes, + } + }) + }, ExplorerEffect::OpenFileTerminalTab { file_path } => { open_file_terminal_tab(app, file_path) }, @@ -100,3 +108,27 @@ fn open_file_terminal_tab(app: &mut App, file_path: PathBuf) -> Task { }, ))) } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::handle_effect; + use crate::app::App; + use crate::widgets::explorer::ExplorerEffect; + + #[test] + fn given_reload_directory_requested_when_handled_then_load_task_is_emitted() + { + let (mut app, _) = App::new(); + + let task = handle_effect( + &mut app, + ExplorerEffect::ReloadDirectoryRequested { + directory: PathBuf::from("/tmp/project"), + }, + ); + + assert_eq!(task.units(), 1); + } +} diff --git a/otty/src/guards.rs b/otty/src/guards.rs index ef08350..74a7ced 100644 --- a/otty/src/guards.rs +++ b/otty/src/guards.rs @@ -96,6 +96,24 @@ pub(crate) fn context_menu_guard(event: &AppEvent) -> MenuGuard { | crate::widgets::settings::SettingsIntent::SaveFailed(_), ), ) => Allow, + AppEvent::Explorer( + crate::widgets::explorer::ExplorerEvent::Effect(_), + ) + | AppEvent::Explorer( + crate::widgets::explorer::ExplorerEvent::Intent( + crate::widgets::explorer::ExplorerIntent::RootLoaded { .. } + | crate::widgets::explorer::ExplorerIntent::FolderLoaded { + .. + } + | crate::widgets::explorer::ExplorerIntent::DirectoryChanged { + .. + } + | crate::widgets::explorer::ExplorerIntent::DirectoryReloaded { + .. + } + | crate::widgets::explorer::ExplorerIntent::LoadFailed { .. }, + ), + ) => Allow, AppEvent::Explorer( crate::widgets::explorer::ExplorerEvent::Intent( crate::widgets::explorer::ExplorerIntent::SyncRoot { .. }, @@ -178,8 +196,22 @@ pub(crate) fn inline_edit_guard(event: &AppEvent) -> bool { ), ) => false, AppEvent::Explorer( + crate::widgets::explorer::ExplorerEvent::Effect(_), + ) + | AppEvent::Explorer( crate::widgets::explorer::ExplorerEvent::Intent( - crate::widgets::explorer::ExplorerIntent::SyncRoot { .. }, + crate::widgets::explorer::ExplorerIntent::SyncRoot { .. } + | crate::widgets::explorer::ExplorerIntent::RootLoaded { .. } + | crate::widgets::explorer::ExplorerIntent::FolderLoaded { + .. + } + | crate::widgets::explorer::ExplorerIntent::DirectoryChanged { + .. + } + | crate::widgets::explorer::ExplorerIntent::DirectoryReloaded { + .. + } + | crate::widgets::explorer::ExplorerIntent::LoadFailed { .. }, ), ) => false, AppEvent::SyncTerminalGridSizes => false, @@ -193,8 +225,11 @@ pub(crate) fn inline_edit_guard(event: &AppEvent) -> bool { #[cfg(test)] mod tests { + use std::path::PathBuf; + use super::{MenuGuard, context_menu_guard, inline_edit_guard}; use crate::events::AppEvent; + use crate::widgets::explorer::{ExplorerEvent, ExplorerIntent}; use crate::widgets::sidebar::{SidebarEvent, SidebarIntent}; #[test] @@ -211,4 +246,28 @@ mod tests { { assert!(!inline_edit_guard(&AppEvent::SyncTerminalGridSizes)); } + + #[test] + fn given_explorer_directory_change_when_context_menu_guard_runs_then_event_is_allowed() + { + let guard = context_menu_guard(&AppEvent::Explorer( + ExplorerEvent::Intent(ExplorerIntent::DirectoryChanged { + directory: PathBuf::from("/tmp/project"), + }), + )); + + assert!(matches!(guard, MenuGuard::Allow)); + } + + #[test] + fn given_explorer_directory_change_when_inline_edit_guard_runs_then_edit_is_not_cancelled() + { + let event = AppEvent::Explorer(ExplorerEvent::Intent( + ExplorerIntent::DirectoryChanged { + directory: PathBuf::from("/tmp/project"), + }, + )); + + assert!(!inline_edit_guard(&event)); + } } diff --git a/otty/src/subscription.rs b/otty/src/subscription.rs index b2a2231..8d2a42f 100644 --- a/otty/src/subscription.rs +++ b/otty/src/subscription.rs @@ -26,6 +26,8 @@ pub(super) fn subscription(app: &App) -> Subscription { } } + subs.push(app.widgets.explorer.subscription().map(AppEvent::Explorer)); + // Quick launch tick for launch indicators and auto-persist if app.widgets.quick_launch.has_active_launches() || app.widgets.quick_launch.state_is_dirty() diff --git a/otty/src/widgets/explorer/event.rs b/otty/src/widgets/explorer/event.rs index e5a200c..81d961f 100644 --- a/otty/src/widgets/explorer/event.rs +++ b/otty/src/widgets/explorer/event.rs @@ -18,6 +18,13 @@ pub(crate) enum ExplorerIntent { path: TreePath, nodes: Vec, }, + /// A watched directory emitted a filesystem tree change. + DirectoryChanged { directory: PathBuf }, + /// Watched directory contents reloaded successfully. + DirectoryReloaded { + directory: PathBuf, + nodes: Vec, + }, /// A directory load operation failed. LoadFailed { message: String }, } @@ -29,6 +36,8 @@ pub(crate) enum ExplorerEffect { LoadRootRequested { root: PathBuf }, /// Request asynchronous loading of a folder's children. LoadFolderRequested { path: TreePath, directory: PathBuf }, + /// Request asynchronous reloading of a watched directory. + ReloadDirectoryRequested { directory: PathBuf }, /// Request opening a file in a command terminal tab. OpenFileTerminalTab { file_path: PathBuf }, } diff --git a/otty/src/widgets/explorer/mod.rs b/otty/src/widgets/explorer/mod.rs index 373b1cf..e1f3c27 100644 --- a/otty/src/widgets/explorer/mod.rs +++ b/otty/src/widgets/explorer/mod.rs @@ -6,9 +6,10 @@ pub(crate) mod services; pub(crate) mod state; pub(crate) mod types; pub(crate) mod view; +mod watcher; pub(crate) use event::{ExplorerEffect, ExplorerEvent, ExplorerIntent}; -use iced::Task; +use iced::{Subscription, Task}; pub(crate) use reducer::ExplorerCtx; use state::ExplorerState; @@ -36,6 +37,11 @@ impl ExplorerWidget { reducer::reduce(&mut self.state, event, ctx) } + /// Return active filesystem watcher subscription for loaded directories. + pub(crate) fn subscription(&self) -> Subscription { + watcher::subscription(self.state.watched_directories()) + } + /// Return a tree view model for the sidebar panel. pub(crate) fn vm(&self) -> model::ExplorerTreeViewModel<'_> { model::ExplorerTreeViewModel { @@ -72,3 +78,36 @@ impl ExplorerWidget { &self.state } } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::{ExplorerCtx, ExplorerIntent, ExplorerWidget}; + + #[test] + fn given_empty_explorer_when_subscription_requested_then_no_units_are_registered() + { + let widget = ExplorerWidget::new(); + + assert_eq!(widget.subscription().units(), 0); + } + + #[test] + fn given_rooted_explorer_when_subscription_requested_then_watcher_unit_is_registered() + { + let mut widget = ExplorerWidget::new(); + let ctx = ExplorerCtx { + active_shell_cwd: None, + }; + + let _task = widget.reduce( + ExplorerIntent::SyncRoot { + cwd: PathBuf::from("/tmp/project"), + }, + &ctx, + ); + + assert_eq!(widget.subscription().units(), 1); + } +} diff --git a/otty/src/widgets/explorer/reducer.rs b/otty/src/widgets/explorer/reducer.rs index 9fc6e75..17bc173 100644 --- a/otty/src/widgets/explorer/reducer.rs +++ b/otty/src/widgets/explorer/reducer.rs @@ -34,6 +34,13 @@ pub(crate) fn reduce( let _ = state.apply_folder_nodes(&path, nodes); Task::none() }, + ExplorerIntent::DirectoryChanged { directory } => { + reduce_directory_changed(state, directory) + }, + ExplorerIntent::DirectoryReloaded { directory, nodes } => { + let _ = state.apply_directory_nodes(&directory, nodes); + Task::none() + }, ExplorerIntent::LoadFailed { message } => { log::warn!("explorer load failed: {message}"); Task::none() @@ -84,6 +91,19 @@ fn reduce_sync_root( })) } +fn reduce_directory_changed( + state: &ExplorerState, + directory: PathBuf, +) -> Task { + if !state.is_watched_directory(&directory) { + return Task::none(); + } + + Task::done(ExplorerEvent::Effect( + ExplorerEffect::ReloadDirectoryRequested { directory }, + )) +} + #[cfg(test)] mod tests { use std::path::PathBuf; @@ -201,6 +221,75 @@ mod tests { assert_eq!(state.tree().len(), 1); } + #[test] + fn given_watched_directory_changed_when_reduced_then_reload_is_requested() { + let mut state = ExplorerState::default(); + let root = PathBuf::from("/tmp/project"); + state.set_root(Some(root.clone())); + let _ = state.apply_root_nodes( + &root, + vec![FileNode::new(String::from("src"), root.join("src"), true)], + ); + + let task = reduce( + &mut state, + ExplorerIntent::DirectoryChanged { + directory: root.clone(), + }, + &ctx(None), + ); + + assert_eq!(task.units(), 1); + } + + #[test] + fn given_unknown_directory_changed_when_reduced_then_reload_is_ignored() { + let mut state = ExplorerState::default(); + let root = PathBuf::from("/tmp/project"); + state.set_root(Some(root.clone())); + let _ = state.apply_root_nodes( + &root, + vec![FileNode::new(String::from("src"), root.join("src"), true)], + ); + + let task = reduce( + &mut state, + ExplorerIntent::DirectoryChanged { + directory: PathBuf::from("/tmp/other"), + }, + &ctx(None), + ); + + assert_eq!(task.units(), 0); + } + + #[test] + fn given_directory_reload_when_reduced_then_directory_nodes_are_applied() { + let mut state = ExplorerState::default(); + let root = PathBuf::from("/tmp/project"); + state.set_root(Some(root.clone())); + let _ = state.apply_root_nodes( + &root, + vec![FileNode::new(String::from("src"), root.join("src"), true)], + ); + + let _task = reduce( + &mut state, + ExplorerIntent::DirectoryReloaded { + directory: root.clone(), + nodes: vec![FileNode::new( + String::from("Cargo.toml"), + root.join("Cargo.toml"), + false, + )], + }, + &ctx(None), + ); + + assert_eq!(state.tree().len(), 1); + assert_eq!(state.tree()[0].name(), "Cargo.toml"); + } + #[test] fn given_folder_node_when_pressed_then_selection_is_set() { let mut state = ExplorerState::default(); diff --git a/otty/src/widgets/explorer/state.rs b/otty/src/widgets/explorer/state.rs index 5edc982..a2ede69 100644 --- a/otty/src/widgets/explorer/state.rs +++ b/otty/src/widgets/explorer/state.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use super::services::root_label; use super::types::{FileNode, TreePath}; @@ -77,7 +77,7 @@ impl ExplorerState { return false; } - self.tree = nodes; + self.tree = merge_nodes(std::mem::take(&mut self.tree), nodes); true } @@ -114,6 +114,32 @@ impl ExplorerState { true } + /// Apply direct children for the directory at a filesystem path. + /// + /// Returns `false` when the directory does not belong to the currently + /// loaded explorer tree. + pub(super) fn apply_directory_nodes( + &mut self, + directory: &Path, + nodes: Vec, + ) -> bool { + if self.root.as_deref() == Some(directory) { + self.tree = merge_nodes(std::mem::take(&mut self.tree), nodes); + return true; + } + + let Some(node) = find_folder_mut_by_path(&mut self.tree, directory) + else { + return false; + }; + if !is_loaded_directory(node) { + return false; + } + + node.children = merge_nodes(std::mem::take(&mut node.children), nodes); + true + } + /// Return whether the node at the provided tree path is a folder. pub(super) fn node_is_folder(&self, path: &[String]) -> Option { find_node(&self.tree, path).map(FileNode::is_folder) @@ -123,6 +149,30 @@ impl ExplorerState { pub(super) fn node_path(&self, path: &[String]) -> Option { find_node(&self.tree, path).map(|node| node.path().to_path_buf()) } + + /// Return directories whose direct children should be watched. + pub(super) fn watched_directories(&self) -> Vec { + let Some(root) = self.root.as_ref() else { + return Vec::new(); + }; + + let mut directories = vec![root.clone()]; + collect_loaded_directories(&self.tree, &mut directories); + directories.sort(); + directories.dedup(); + directories + } + + /// Return whether the path is a currently watched explorer directory. + pub(super) fn is_watched_directory(&self, directory: &Path) -> bool { + if self.root.as_deref() == Some(directory) { + return true; + } + + find_folder_by_path(&self.tree, directory) + .map(is_loaded_directory) + .unwrap_or(false) + } } fn find_node<'a>( @@ -163,10 +213,100 @@ fn find_node_mut<'a>( find_node_mut(&mut node.children, tail) } +fn find_folder_by_path<'a>( + nodes: &'a [FileNode], + path: &Path, +) -> Option<&'a FileNode> { + for node in nodes { + if node.is_folder() && node.path() == path { + return Some(node); + } + + if node.is_folder() { + if let Some(found) = find_folder_by_path(node.children(), path) { + return Some(found); + } + } + } + + None +} + +fn find_folder_mut_by_path<'a>( + nodes: &'a mut [FileNode], + path: &Path, +) -> Option<&'a mut FileNode> { + for node in nodes { + if node.is_folder() && node.path() == path { + return Some(node); + } + + if node.is_folder() { + if let Some(found) = + find_folder_mut_by_path(&mut node.children, path) + { + return Some(found); + } + } + } + + None +} + fn find_child_index(nodes: &[FileNode], name: &str) -> Option { nodes.iter().position(|node| node.name() == name) } +fn collect_loaded_directories( + nodes: &[FileNode], + directories: &mut Vec, +) { + for node in nodes { + if !node.is_folder() { + continue; + } + + if is_loaded_directory(node) { + directories.push(node.path().to_path_buf()); + } + + collect_loaded_directories(node.children(), directories); + } +} + +fn is_loaded_directory(node: &FileNode) -> bool { + node.is_folder() && (node.is_expanded() || !node.children().is_empty()) +} + +fn merge_nodes( + previous: Vec, + mut next: Vec, +) -> Vec { + let mut previous = previous; + for node in &mut next { + let Some(index) = previous.iter().position(|candidate| { + candidate.path == node.path && candidate.is_folder == node.is_folder + }) else { + continue; + }; + + preserve_folder_state(node, previous.swap_remove(index)); + } + + next +} + +fn preserve_folder_state(node: &mut FileNode, previous: FileNode) { + if !node.is_folder || !previous.is_folder { + return; + } + + node.is_expanded = previous.is_expanded; + if node.is_expanded || !previous.children.is_empty() { + node.children = previous.children; + } +} + #[cfg(test)] mod tests { use std::path::PathBuf; @@ -232,4 +372,123 @@ mod tests { state.node_path(&[String::from("src"), String::from("main.rs")]); assert_eq!(path, Some(root.join("src/main.rs"))); } + + #[test] + fn given_root_reloaded_when_folder_was_expanded_then_expansion_is_preserved() + { + let mut state = ExplorerState::default(); + let root = PathBuf::from("/tmp"); + state.set_root(Some(root.clone())); + let _ = state.apply_root_nodes( + &root, + vec![FileNode::new(String::from("src"), root.join("src"), true)], + ); + let _ = state.toggle_folder(&[String::from("src")]); + let _ = state.apply_folder_nodes( + &[String::from("src")], + vec![FileNode::new( + String::from("main.rs"), + root.join("src/main.rs"), + false, + )], + ); + + let changed = state.apply_directory_nodes( + &root, + vec![ + FileNode::new(String::from("src"), root.join("src"), true), + FileNode::new( + String::from("Cargo.toml"), + root.join("Cargo.toml"), + false, + ), + ], + ); + + assert!(changed); + assert_eq!(state.tree().len(), 2); + assert!(state.tree()[0].is_expanded()); + assert_eq!(state.tree()[0].children().len(), 1); + assert_eq!(state.tree()[0].children()[0].name(), "main.rs"); + } + + #[test] + fn given_loaded_folder_reloaded_when_child_folder_was_expanded_then_nested_expansion_is_preserved() + { + let mut state = ExplorerState::default(); + let root = PathBuf::from("/tmp"); + state.set_root(Some(root.clone())); + let _ = state.apply_root_nodes( + &root, + vec![FileNode::new(String::from("src"), root.join("src"), true)], + ); + let _ = state.toggle_folder(&[String::from("src")]); + let _ = state.apply_folder_nodes( + &[String::from("src")], + vec![FileNode::new( + String::from("nested"), + root.join("src/nested"), + true, + )], + ); + let _ = + state.toggle_folder(&[String::from("src"), String::from("nested")]); + let _ = state.apply_folder_nodes( + &[String::from("src"), String::from("nested")], + vec![FileNode::new( + String::from("lib.rs"), + root.join("src/nested/lib.rs"), + false, + )], + ); + + let changed = state.apply_directory_nodes( + &root.join("src"), + vec![ + FileNode::new( + String::from("nested"), + root.join("src/nested"), + true, + ), + FileNode::new( + String::from("main.rs"), + root.join("src/main.rs"), + false, + ), + ], + ); + + assert!(changed); + let src = &state.tree()[0]; + assert_eq!(src.children().len(), 2); + assert!(src.children()[0].is_expanded()); + assert_eq!(src.children()[0].children().len(), 1); + assert_eq!(src.children()[0].children()[0].name(), "lib.rs"); + } + + #[test] + fn given_loaded_folder_when_watch_directories_requested_then_root_and_loaded_folder_are_returned() + { + let mut state = ExplorerState::default(); + let root = PathBuf::from("/tmp"); + state.set_root(Some(root.clone())); + let _ = state.apply_root_nodes( + &root, + vec![FileNode::new(String::from("src"), root.join("src"), true)], + ); + let _ = state.toggle_folder(&[String::from("src")]); + let _ = state.apply_folder_nodes( + &[String::from("src")], + vec![FileNode::new( + String::from("main.rs"), + root.join("src/main.rs"), + false, + )], + ); + let _ = state.toggle_folder(&[String::from("src")]); + + let directories = state.watched_directories(); + + assert_eq!(directories, vec![root.clone(), root.join("src")]); + } } diff --git a/otty/src/widgets/explorer/watcher.rs b/otty/src/widgets/explorer/watcher.rs new file mode 100644 index 0000000..f0ec1b7 --- /dev/null +++ b/otty/src/widgets/explorer/watcher.rs @@ -0,0 +1,424 @@ +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use iced::Subscription; +use iced::futures::channel::mpsc; +use iced::futures::stream::BoxStream; +use iced::futures::{FutureExt, SinkExt, StreamExt}; +use notify::event::ModifyKind; +use notify::{EventKind, RecursiveMode, Watcher}; + +use super::{ExplorerEvent, ExplorerIntent}; + +const WATCH_CHANNEL_SIZE: usize = 100; + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct ExplorerWatchDirectories { + directories: Vec, +} + +impl ExplorerWatchDirectories { + fn new(mut directories: Vec) -> Self { + directories.sort(); + directories.dedup(); + + Self { directories } + } + + fn is_empty(&self) -> bool { + self.directories.is_empty() + } +} + +#[derive(Debug)] +struct WatchedDirectories { + directories: BTreeSet, +} + +impl WatchedDirectories { + fn new(directories: Vec) -> Self { + Self { + directories: directories.into_iter().collect(), + } + } + + fn contains(&self, path: &Path) -> bool { + self.directories.contains(path) + } + + fn iter(&self) -> impl Iterator { + self.directories.iter() + } + + fn nearest_ancestor(&self, path: &Path) -> Option { + self.directories + .iter() + .filter(|directory| path.starts_with(directory)) + .max_by_key(|directory| directory.components().count()) + .cloned() + } +} + +/// Subscribe to filesystem changes for currently loaded explorer directories. +pub(super) fn subscription( + directories: Vec, +) -> Subscription { + let directories = ExplorerWatchDirectories::new(directories); + if directories.is_empty() { + return Subscription::none(); + } + + Subscription::run_with(directories, explorer_watch_stream) +} + +fn explorer_watch_stream( + data: &ExplorerWatchDirectories, +) -> BoxStream<'static, ExplorerEvent> { + let directories = data.directories.clone(); + + Box::pin(iced::stream::channel( + WATCH_CHANNEL_SIZE, + async move |mut output| { + let watched_directories = WatchedDirectories::new(directories); + let (event_sender, mut event_receiver) = + mpsc::unbounded::>(); + + let mut watcher = match notify::recommended_watcher(move |result| { + let _ = event_sender.unbounded_send(result); + }) { + Ok(watcher) => watcher, + Err(err) => { + log::warn!("explorer filesystem watcher failed: {err}"); + return; + }, + }; + + for directory in watched_directories.iter() { + if let Err(err) = + watcher.watch(directory, RecursiveMode::NonRecursive) + { + let display = directory.display(); + log::warn!("explorer failed to watch {display}: {err}"); + } + } + + let _watcher = watcher; + while let Some(result) = event_receiver.next().await { + let mut changed_directories = BTreeSet::new(); + collect_changed_directories( + result, + &watched_directories, + &mut changed_directories, + ); + + while let Some(Some(result)) = + event_receiver.next().now_or_never() + { + collect_changed_directories( + result, + &watched_directories, + &mut changed_directories, + ); + } + + for directory in changed_directories { + let event = ExplorerEvent::Intent( + ExplorerIntent::DirectoryChanged { directory }, + ); + if output.send(event).await.is_err() { + return; + } + } + } + }, + )) +} + +fn collect_changed_directories( + result: notify::Result, + watched: &WatchedDirectories, + changed_directories: &mut BTreeSet, +) { + let event = match result { + Ok(event) => event, + Err(err) => { + log::warn!("explorer filesystem watcher event failed: {err}"); + return; + }, + }; + + for directory in changed_directories_from_event(&event, watched) { + changed_directories.insert(directory); + } +} + +fn changed_directories_from_event( + event: ¬ify::Event, + watched: &WatchedDirectories, +) -> Vec { + if !event_may_change_tree(event.kind) { + return Vec::new(); + } + + let mut directories = BTreeSet::new(); + for path in &event.paths { + if let Some(parent) = path.parent() { + if watched.contains(parent) { + directories.insert(parent.to_path_buf()); + continue; + } + } + + if watched.contains(path) { + directories.insert(path.clone()); + continue; + } + + if let Some(directory) = watched.nearest_ancestor(path) { + directories.insert(directory); + } + } + + directories.into_iter().collect() +} + +fn event_may_change_tree(kind: EventKind) -> bool { + matches!( + kind, + EventKind::Any + | EventKind::Create(_) + | EventKind::Remove(_) + | EventKind::Modify(ModifyKind::Any) + | EventKind::Modify(ModifyKind::Name(_)) + | EventKind::Modify(ModifyKind::Other) + | EventKind::Other + ) +} + +#[cfg(test)] +mod tests { + use notify::event::{ + AccessKind, CreateKind, DataChange, ModifyKind, RemoveKind, RenameMode, + }; + + use super::*; + + #[test] + fn given_duplicate_watch_directories_when_normalized_then_sorted_unique_directories_are_kept() + { + let root = PathBuf::from("/tmp/project"); + let src = root.join("src"); + + let directories = ExplorerWatchDirectories::new(vec![ + src.clone(), + root.clone(), + src.clone(), + ]); + + assert_eq!(directories.directories, vec![root, src]); + } + + #[test] + fn given_empty_directories_when_subscription_created_then_no_units_are_registered() + { + let subscription = subscription(Vec::new()); + + assert_eq!(subscription.units(), 0); + } + + #[test] + fn given_watch_directories_when_subscription_created_then_one_unit_is_registered() + { + let subscription = subscription(vec![PathBuf::from("/tmp/project")]); + + assert_eq!(subscription.units(), 1); + } + + #[test] + fn given_create_event_for_child_when_mapped_then_parent_directory_is_returned() + { + let root = PathBuf::from("/tmp/project"); + let src = root.join("src"); + let watched = WatchedDirectories::new(vec![root.clone(), src.clone()]); + let event = notify::Event::new(EventKind::Create(CreateKind::File)) + .add_path(src.join("main.rs")); + + let directories = changed_directories_from_event(&event, &watched); + + assert_eq!(directories, vec![src]); + } + + #[test] + fn given_create_event_for_root_child_when_mapped_then_root_is_returned() { + let root = PathBuf::from("/tmp/project"); + let watched = WatchedDirectories::new(vec![root.clone()]); + let event = notify::Event::new(EventKind::Create(CreateKind::Folder)) + .add_path(root.join("src")); + + let directories = changed_directories_from_event(&event, &watched); + + assert_eq!(directories, vec![root]); + } + + #[test] + fn given_rename_event_with_two_paths_when_mapped_then_both_parent_directories_are_returned() + { + let root = PathBuf::from("/tmp/project"); + let src = root.join("src"); + let tests = root.join("tests"); + let watched = WatchedDirectories::new(vec![ + root.clone(), + src.clone(), + tests.clone(), + ]); + let event = notify::Event::new(EventKind::Modify(ModifyKind::Name( + RenameMode::Both, + ))) + .add_path(src.join("main.rs")) + .add_path(tests.join("main.rs")); + + let directories = changed_directories_from_event(&event, &watched); + + assert_eq!(directories, vec![src, tests]); + } + + #[test] + fn given_remove_event_for_watched_directory_when_mapped_then_parent_directory_is_returned() + { + let root = PathBuf::from("/tmp/project"); + let src = root.join("src"); + let watched = WatchedDirectories::new(vec![root.clone(), src.clone()]); + let event = notify::Event::new(EventKind::Remove(RemoveKind::Folder)) + .add_path(src); + + let directories = changed_directories_from_event(&event, &watched); + + assert_eq!(directories, vec![root]); + } + + #[test] + fn given_nested_event_without_watched_parent_when_mapped_then_nearest_watched_ancestor_is_returned() + { + let root = PathBuf::from("/tmp/project"); + let watched = WatchedDirectories::new(vec![root.clone()]); + let event = notify::Event::new(EventKind::Create(CreateKind::File)) + .add_path(root.join("src/nested/main.rs")); + + let directories = changed_directories_from_event(&event, &watched); + + assert_eq!(directories, vec![root]); + } + + #[test] + fn given_event_for_watched_directory_when_mapped_then_same_directory_is_returned() + { + let root = PathBuf::from("/tmp/project"); + let watched = WatchedDirectories::new(vec![root.clone()]); + let event = notify::Event::new(EventKind::Any).add_path(root.clone()); + + let directories = changed_directories_from_event(&event, &watched); + + assert_eq!(directories, vec![root]); + } + + #[test] + fn given_event_outside_watched_tree_when_mapped_then_no_directory_is_returned() + { + let root = PathBuf::from("/tmp/project"); + let watched = WatchedDirectories::new(vec![root]); + let event = notify::Event::new(EventKind::Create(CreateKind::File)) + .add_path(PathBuf::from("/tmp/other/main.rs")); + + let directories = changed_directories_from_event(&event, &watched); + + assert!(directories.is_empty()); + } + + #[test] + fn given_imprecise_modify_events_when_mapped_then_parent_directory_is_returned() + { + let root = PathBuf::from("/tmp/project"); + let watched = WatchedDirectories::new(vec![root.clone()]); + let kinds = [ + EventKind::Modify(ModifyKind::Any), + EventKind::Modify(ModifyKind::Other), + EventKind::Other, + ]; + + for kind in kinds { + let event = notify::Event::new(kind).add_path(root.join("main.rs")); + + let directories = changed_directories_from_event(&event, &watched); + + assert_eq!(directories, vec![root.clone()]); + } + } + + #[test] + fn given_data_modify_event_when_mapped_then_no_directory_is_returned() { + let root = PathBuf::from("/tmp/project"); + let watched = WatchedDirectories::new(vec![root.clone()]); + let event = notify::Event::new(EventKind::Modify(ModifyKind::Data( + DataChange::Content, + ))) + .add_path(root.join("main.rs")); + + let directories = changed_directories_from_event(&event, &watched); + + assert!(directories.is_empty()); + } + + #[test] + fn given_metadata_modify_event_when_mapped_then_no_directory_is_returned() { + let root = PathBuf::from("/tmp/project"); + let watched = WatchedDirectories::new(vec![root.clone()]); + let event = notify::Event::new(EventKind::Modify( + ModifyKind::Metadata(notify::event::MetadataKind::Any), + )) + .add_path(root.join("main.rs")); + + let directories = changed_directories_from_event(&event, &watched); + + assert!(directories.is_empty()); + } + + #[test] + fn given_access_event_when_mapped_then_no_directory_is_returned() { + let root = PathBuf::from("/tmp/project"); + let watched = WatchedDirectories::new(vec![root.clone()]); + let event = notify::Event::new(EventKind::Access(AccessKind::Any)) + .add_path(root.join("main.rs")); + + let directories = changed_directories_from_event(&event, &watched); + + assert!(directories.is_empty()); + } + + #[test] + fn given_notify_event_when_collected_then_directory_is_inserted() { + let root = PathBuf::from("/tmp/project"); + let watched = WatchedDirectories::new(vec![root.clone()]); + let mut directories = BTreeSet::new(); + let event = notify::Event::new(EventKind::Create(CreateKind::File)) + .add_path(root.join("main.rs")); + + collect_changed_directories(Ok(event), &watched, &mut directories); + + assert_eq!(directories.into_iter().collect::>(), vec![root]); + } + + #[test] + fn given_notify_error_when_collected_then_no_directory_is_returned() { + let root = PathBuf::from("/tmp/project"); + let watched = WatchedDirectories::new(vec![root]); + let mut directories = BTreeSet::new(); + + collect_changed_directories( + Err(notify::Error::generic("boom")), + &watched, + &mut directories, + ); + + assert!(directories.is_empty()); + } +}