Unnamed repository; edit this file 'description' to name the repository.
implement file-watching based on filesentry
| -rw-r--r-- | Cargo.lock | 65 | ||||
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | helix-core/Cargo.toml | 3 | ||||
| -rw-r--r-- | helix-core/src/file_watcher.rs | 517 | ||||
| -rw-r--r-- | helix-core/src/lib.rs | 1 | ||||
| -rw-r--r-- | helix-dap/src/lib.rs | 1 | ||||
| -rw-r--r-- | helix-lsp/Cargo.toml | 1 | ||||
| -rw-r--r-- | helix-lsp/src/file_event.rs | 98 | ||||
| -rw-r--r-- | helix-term/Cargo.toml | 2 | ||||
| -rw-r--r-- | helix-term/src/commands/typed.rs | 24 | ||||
| -rw-r--r-- | helix-term/src/events.rs | 2 | ||||
| -rw-r--r-- | helix-term/src/handlers.rs | 4 | ||||
| -rw-r--r-- | helix-term/src/handlers/auto_reload.rs | 104 | ||||
| -rw-r--r-- | helix-term/src/ui/editor.rs | 2 | ||||
| -rw-r--r-- | helix-view/src/document.rs | 2 | ||||
| -rw-r--r-- | helix-view/src/editor.rs | 44 | ||||
| -rw-r--r-- | helix-view/src/handlers.rs | 8 | ||||
| -rw-r--r-- | helix-view/src/handlers/lsp.rs | 16 |
18 files changed, 826 insertions, 69 deletions
@@ -319,6 +319,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] +name = "ecow" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78e4f79b296fbaab6ce2e22d52cb4c7f010fe0ebe7a32e34fa25885fd797bd02" + +[[package]] name = "either" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -407,6 +413,24 @@ dependencies = [ ] [[package]] +name = "filesentry" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78347b6abab87ab712230b933994b5611302f82d64747e681477adcf26dedbd" +dependencies = [ + "bitflags", + "ecow", + "hashbrown 0.15.5", + "ignore", + "log", + "memchr", + "mio", + "papaya", + "rustix 1.1.2", + "walkdir", +] + +[[package]] name = "filetime" version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1392,11 +1416,14 @@ dependencies = [ "bitflags", "chrono", "encoding_rs", + "filesentry", "foldhash 0.2.0", "globset", + "helix-event", "helix-loader", "helix-parsec", "helix-stdx", + "ignore", "imara-diff 0.2.0", "indoc", "log", @@ -1481,6 +1508,7 @@ dependencies = [ "futures-util", "globset", "helix-core", + "helix-event", "helix-loader", "helix-lsp-types", "helix-stdx", @@ -2049,9 +2077,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.4" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memmap2" @@ -2082,15 +2110,14 @@ dependencies = [ [[package]] name = "mio" -version = "1.0.2" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ - "hermit-abi", "libc", "log", "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2160,6 +2187,16 @@ dependencies = [ ] [[package]] +name = "papaya" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f92dd0b07c53a0a0c764db2ace8c541dc47320dad97c2200c2a637ab9dd2328f" +dependencies = [ + "equivalent", + "seize", +] + +[[package]] name = "parking_lot" version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2396,7 +2433,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.59.0", + "windows-sys 0.61.1", ] [[package]] @@ -2421,6 +2458,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.1", +] + +[[package]] name = "serde" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2648,7 +2695,7 @@ dependencies = [ "getrandom 0.3.1", "once_cell", "rustix 1.1.2", - "windows-sys 0.59.0", + "windows-sys 0.61.1", ] [[package]] @@ -2662,7 +2709,7 @@ dependencies = [ "parking_lot", "rustix 1.1.2", "signal-hook", - "windows-sys 0.59.0", + "windows-sys 0.61.1", ] [[package]] @@ -52,6 +52,7 @@ futures-util = { version = "0.3", features = ["std", "async-await"], default-fea tokio-stream = "0.1.17" toml = "0.9" termina = "0.1" +ignore = "0.4" [workspace.package] version = "25.7.1" diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index ead75f9a..2402624e 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -18,8 +18,11 @@ integration = [] [dependencies] helix-stdx = { path = "../helix-stdx" } helix-loader = { path = "../helix-loader" } +helix-event = { path = "../helix-event" } helix-parsec = { path = "../helix-parsec" } +filesentry = "0.2.1" +ignore = "0.4" ropey.workspace = true smallvec = "1.15" smartstring = "1.0.1" diff --git a/helix-core/src/file_watcher.rs b/helix-core/src/file_watcher.rs new file mode 100644 index 00000000..9e49bab9 --- /dev/null +++ b/helix-core/src/file_watcher.rs @@ -0,0 +1,517 @@ +use std::borrow::Borrow; +use std::mem::replace; +use std::path::{Path, PathBuf}; +use std::slice; +use std::sync::Arc; + +pub use filesentry::{Event, EventType, Events}; +use filesentry::{Filter, ShutdownOnDrop}; +use helix_event::{dispatch, events}; +use ignore::gitignore::{Gitignore, GitignoreBuilder}; +use serde::{Deserialize, Serialize}; + +events! { + FileSystemDidChange { + fs_events: Events + } +} + +/// Config for file watching +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", default, deny_unknown_fields)] +pub struct Config { + /// Enable file watching enable by default + pub enable: bool, + pub watch_vcs: bool, + /// Only enable the file watcher inside helix workspaces (VCS repos and directories with .helix + /// directory) this prevents watching large directories like $HOME by default + /// + /// Defaults to `true` + pub require_workspace: bool, + /// Enables ignoring hidden files. + /// Whether to hide hidden files in file picker and global search results. Defaults to true. + pub hidden: bool, + /// Enables reading `.ignore` files. + /// Whether to hide files listed in .ignore in file picker and global search results. Defaults to true. + pub ignore: bool, + /// Enables reading `.gitignore` files. + /// Whether to hide files listed in .gitignore in file picker and global search results. Defaults to true. + pub git_ignore: bool, + /// Enables reading global .gitignore, whose path is specified in git's config: `core.excludefile` option. + /// Whether to hide files listed in global .gitignore in file picker and global search results. Defaults to true. + pub git_global: bool, + // /// Enables reading `.git/info/exclude` files. + // /// Whether to hide files listed in .git/info/exclude in file picker and global search results. Defaults to true. + // pub git_exclude: bool, + /// Maximum Depth to recurse for filewatching + pub max_depth: Option<usize>, +} + +impl Default for Config { + fn default() -> Self { + Config { + enable: true, + watch_vcs: false, + require_workspace: true, + hidden: true, + ignore: true, + git_ignore: true, + git_global: true, + max_depth: Some(10), + } + } +} +pub struct Watcher { + watcher: Option<(filesentry::Watcher, ShutdownOnDrop)>, + filter: Arc<WatchFilter>, + roots: Vec<(PathBuf, usize)>, + config: Config, +} + +impl Watcher { + pub fn new(config: &Config) -> Watcher { + let mut watcher = Watcher { + watcher: None, + filter: Arc::new(WatchFilter { + filesentry_ignores: Gitignore::empty(), + ignore_files: Vec::new(), + global_ignores: Vec::new(), + hidden: true, + watch_vcs: true, + }), + roots: Vec::new(), + config: config.clone(), + }; + watcher.reload(config); + watcher + } + + pub fn reload(&mut self, config: &Config) { + let old_config = replace(&mut self.config, config.clone()); + let (workspace, no_workspace) = helix_loader::find_workspace(); + if !config.enable || config.require_workspace && no_workspace { + self.watcher = None; + return; + } + self.filter = Arc::new(WatchFilter::new( + config, + &workspace, + self.roots.iter().map(|(it, _)| &**it), + )); + let watcher = match &mut self.watcher { + Some((watcher, _)) => { + // TODO: more fine grained detection of when recrawl is nedded + watcher.set_filter(self.filter.clone(), old_config != self.config); + watcher + } + None => match filesentry::Watcher::new() { + Ok(watcher) => { + watcher.set_filter(self.filter.clone(), false); + watcher.add_handler(move |events| { + dispatch(FileSystemDidChange { fs_events: events }); + true + }); + let shutdown_guard = watcher.shutdown_guard(); + &mut self.watcher.insert((watcher, shutdown_guard)).0 + } + Err(err) => { + log::error!("failed to start file-watcher: {err}"); + return; + } + }, + }; + if let Err(err) = watcher.add_root(&workspace, true, |_| ()) { + log::error!("failed to start file-watcher: {err}"); + } + for (root, _) in &self.roots { + if let Err(err) = watcher.add_root(root, true, |_| ()) { + log::error!("failed to start file-watcher: {err}"); + } + } + watcher.start(); + } + + pub fn remove_root(&mut self, root: PathBuf) { + let i = self.roots.partition_point(|(it, _)| it < &root); + if self.roots.get(i).is_none_or(|(it, _)| it != &root) { + log::error!("tried to remove root {root:?} from watch list that does not exist!"); + return; + } + if self.roots[i].1 <= 1 { + self.roots.remove(i); + } else { + self.roots[i].1 -= 1; + } + } + + pub fn add_root(&mut self, root: &Path) { + let root = match root.canonicalize() { + Ok(root) => root, + Err(err) => { + log::error!("failed to watch {root:?}: {err}"); + return; + } + }; + let i = self.roots.partition_point(|(it, _)| it < &root); + if let Some((_, refcnt)) = self.roots.get_mut(i).filter(|(path, _)| path == &root) { + *refcnt += 1; + return; + } + if self.roots[..i] + .iter() + .rev() + .find(|(it, _)| it.parent().is_none_or(|it| root.starts_with(it))) + .is_some_and(|(it, _)| root.starts_with(it)) + && !self.filter.ignore_path_rec(&root, Some(true)) + { + return; + } + let (workspace, _) = helix_loader::find_workspace(); + self.roots.push((root.clone(), 1)); + self.filter = Arc::new(WatchFilter::new( + &self.config, + &workspace, + self.roots.iter().map(|(it, _)| &**it), + )); + if let Some((watcher, _)) = &self.watcher { + watcher.set_filter(self.filter.clone(), false); + if let Err(err) = watcher.add_root(&root, true, |_| ()) { + log::error!("failed to watch {root:?}: {err}"); + } + } + } +} + +fn build_ignore(paths: impl IntoIterator<Item = PathBuf> + Clone, dir: &Path) -> Option<Gitignore> { + let mut builder = GitignoreBuilder::new(dir); + for path in paths.clone() { + if let Some(err) = builder.add(&path) { + if !err.is_io() { + log::error!("failed to read ignorefile at {path:?}: {err}"); + } + } + } + match builder.build() { + Ok(ignore) => (!ignore.is_empty()).then_some(ignore), + Err(err) => { + if !err.is_io() { + log::error!( + "failed to read ignorefile at {:?}: {err}", + paths.into_iter().collect::<Vec<_>>() + ); + } + None + } + } +} + +struct IgnoreFiles { + root: PathBuf, + ignores: Vec<Arc<Gitignore>>, +} + +impl IgnoreFiles { + fn new( + workspace_ignore: Option<Arc<Gitignore>>, + config: &Config, + root: &Path, + globals: &[Arc<Gitignore>], + ) -> Self { + let mut ignores = Vec::with_capacity(8); + // .helix/ignore + if let Some(workspace_ignore) = workspace_ignore { + ignores.push(workspace_ignore); + } + for ancestor in root.ancestors() { + let ignore = if config.ignore { + if config.git_ignore { + // the second path takes priority + build_ignore( + [ancestor.join(".gitignore"), ancestor.join(".ignore")], + ancestor, + ) + } else { + build_ignore([ancestor.join(".ignore")], ancestor) + } + } else if config.git_ignore { + build_ignore([ancestor.join(".gitignore")], ancestor) + } else { + None + }; + if let Some(ignore) = ignore { + ignores.push(Arc::new(ignore)); + } + } + ignores.extend(globals.iter().cloned()); + Self { + root: root.into(), + ignores, + } + } + + fn shared_ignores( + workspace: &Path, + config: &Config, + ) -> (Vec<Arc<Gitignore>>, Option<Arc<Gitignore>>) { + let mut ignores = Vec::new(); + let workspace_ignore = build_ignore( + [ + helix_loader::config_dir().join("ignore"), + workspace.join(".helix/ignore"), + ], + workspace, + ) + .map(Arc::new); + if config.git_global { + let (gitignore_global, err) = Gitignore::global(); + if let Some(err) = err { + if !err.is_io() { + log::error!("failed to read global global ignorefile: {err}"); + } + } + if !gitignore_global.is_empty() { + ignores.push(Arc::new(gitignore_global)); + } + } + // if config.git_exclude { + // TODO git_exclude implementation, this isn't quite trivial unfortunaetly + // due to detached workspace etc. + // } + // TODO: git exclude + (ignores, workspace_ignore) + } + + fn filesentry_ignores(workspace: &Path) -> Gitignore { + // the second path takes priority + build_ignore( + [ + helix_loader::config_dir().join("filesentryignore"), + workspace.join(".helix/filesentryignore"), + ], + workspace, + ) + .unwrap_or(Gitignore::empty()) + } + + fn is_ignored( + ignores: &[impl Borrow<Gitignore>], + path: &Path, + is_dir: Option<bool>, + ) -> Option<bool> { + match is_dir { + Some(is_dir) => { + for ignore in ignores { + match ignore.borrow().matched(path, is_dir) { + ignore::Match::None => continue, + ignore::Match::Ignore(_) => return Some(true), + ignore::Match::Whitelist(_) => return Some(false), + } + } + } + None => { + // if we don't know wether this is a directory (on windows) + // then we are conservative and allow the dirs + for ignore in ignores { + match ignore.borrow().matched(path, true) { + ignore::Match::None => continue, + ignore::Match::Ignore(glob) => { + if glob.is_only_dir() { + match ignore.borrow().matched(path, false) { + ignore::Match::None => continue, + ignore::Match::Ignore(_) => return Some(true), + ignore::Match::Whitelist(_) => return Some(false), + } + } else { + return Some(true); + } + } + ignore::Match::Whitelist(_) => return Some(false), + } + } + } + } + None + } +} + +/// a filter to ignore hiddeng/ingored files. The point of this +/// is to avoid overwhelming the watcher with watching a ton of +/// files/directories (like the cargo target directory, node_modules or +/// VCS files) so ignoring a file is a performance optimization. +/// +/// By default we ignore ignored +struct WatchFilter { + filesentry_ignores: Gitignore, + ignore_files: Vec<IgnoreFiles>, + global_ignores: Vec<Arc<Gitignore>>, + hidden: bool, + watch_vcs: bool, +} + +impl WatchFilter { + fn new<'a>( + config: &Config, + workspace: &'a Path, + roots: impl Iterator<Item = &'a Path> + Clone, + ) -> WatchFilter { + let filesentry_ignores = IgnoreFiles::filesentry_ignores(workspace); + let (global_ignores, workspace_ignore) = IgnoreFiles::shared_ignores(workspace, config); + let ignore_files = roots + .chain([workspace]) + .map(|root| IgnoreFiles::new(workspace_ignore.clone(), config, root, &global_ignores)) + .collect(); + WatchFilter { + filesentry_ignores, + ignore_files, + global_ignores, + hidden: config.hidden, + watch_vcs: config.watch_vcs, + } + } + + fn ignore_path_impl( + &self, + path: &Path, + is_dir: Option<bool>, + ignore_files: &[Arc<Gitignore>], + ) -> bool { + if let Some(ignore) = + IgnoreFiles::is_ignored(slice::from_ref(&self.filesentry_ignores), path, is_dir) + { + return ignore; + } + if is_hardcoded_whitelist(path) { + return false; + } + if is_hardcoded_blacklist(path, is_dir.unwrap_or(false)) { + return true; + } + if let Some(ignore) = IgnoreFiles::is_ignored(ignore_files, path, is_dir) { + return ignore; + } + // ignore .git dircectory except .git/HEAD (and .git itself) + if is_vcs_ignore(path, self.watch_vcs) { + return true; + } + !self.hidden && is_hidden(path) + } +} + +impl filesentry::Filter for WatchFilter { + fn ignore_path(&self, path: &Path, is_dir: Option<bool>) -> bool { + let i = self + .ignore_files + .partition_point(|ignore_files| path < ignore_files.root); + let (root, ignore_files) = self + .ignore_files + .get(i) + .map_or((Path::new(""), &self.global_ignores), |files| { + (&files.root, &files.ignores) + }); + if path == root { + return false; + } + self.ignore_path_impl(path, is_dir, ignore_files) + } + + fn ignore_path_rec(&self, mut path: &Path, is_dir: Option<bool>) -> bool { + let i = self + .ignore_files + .partition_point(|ignore_files| path < ignore_files.root); + let (root, ignore_files) = self + .ignore_files + .get(i) + .map_or((Path::new(""), &self.global_ignores), |files| { + (&files.root, &files.ignores) + }); + loop { + if path == root { + return false; + } + if self.ignore_path_impl(path, is_dir, ignore_files) { + return true; + } + let Some(parent) = path.parent() else { + break; + }; + path = parent; + } + false + } +} + +fn is_hidden(path: &Path) -> bool { + path.file_name().is_some_and(|it| { + it.as_encoded_bytes().first() == Some(&b'.') + // handled by vcs ignore rules + && it != ".git" + }) +} + +// hidden directories we want to watch by default +fn is_hardcoded_whitelist(path: &Path) -> bool { + path.ends_with(".helix") + | path.ends_with(".github") + | path.ends_with(".cargo") + | path.ends_with(".envrc") +} + +fn is_hardcoded_blacklist(path: &Path, is_dir: bool) -> bool { + // don't descend into the cargo regstiry and similar + path.parent() + .is_some_and(|parent| parent.ends_with(".cargo")) + && is_dir +} + +fn file_name(path: &Path) -> Option<&str> { + path.file_name().and_then(|it| it.to_str()) +} + +fn is_vcs_ignore(path: &Path, watch_vcs: bool) -> bool { + // ignore .git dircectory except .git/HEAD (and .git itself) + if watch_vcs + && path.parent().is_some_and(|it| it.ends_with(".git")) + && !path.ends_with(".git/HEAD") + { + return true; + } + match file_name(path) { + Some(".jj" | ".svn" | ".hg") => true, + Some(".git") => !watch_vcs, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use crate::file_watcher::{is_hardcoded_whitelist, is_hidden, is_vcs_ignore}; + + #[test] + fn test_vcs_ignore() { + assert!(!is_vcs_ignore(Path::new(".git"), true)); + assert!(!is_vcs_ignore(Path::new(".git/HEAD"), true)); + assert!(is_vcs_ignore(Path::new(".git/foo"), true)); + assert!(is_vcs_ignore(Path::new(".git/foo/bar"), true)); + assert!(!is_vcs_ignore(Path::new(".foo"), true)); + assert!(is_vcs_ignore(Path::new(".jj"), true)); + assert!(is_vcs_ignore(Path::new(".svn"), true)); + assert!(is_vcs_ignore(Path::new(".hg"), true)); + } + + #[test] + fn test_hidden() { + assert!(is_hidden(Path::new(".foo"))); + // handled by vcs ignore rules + assert!(!is_hidden(Path::new(".git"))); + } + + #[test] + fn test_whitelist() { + assert!(is_hardcoded_whitelist(Path::new(".git"))); + assert!(is_hardcoded_whitelist(Path::new(".helix"))); + assert!(is_hardcoded_whitelist(Path::new(".github"))); + assert!(!is_hardcoded_whitelist(Path::new(".githup"))); + } +} diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index 09865ca4..1e25eafa 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod diagnostic; pub mod diff; pub mod doc_formatter; pub mod editor_config; +pub mod file_watcher; pub mod fuzzy; pub mod graphemes; pub mod history; diff --git a/helix-dap/src/lib.rs b/helix-dap/src/lib.rs index 16c84f66..9f61f42d 100644 --- a/helix-dap/src/lib.rs +++ b/helix-dap/src/lib.rs @@ -9,6 +9,7 @@ pub use types::*; use serde::de::DeserializeOwned; + use thiserror::Error; #[derive(Error, Debug)] pub enum Error { diff --git a/helix-lsp/Cargo.toml b/helix-lsp/Cargo.toml index 20c3e78b..b2557c93 100644 --- a/helix-lsp/Cargo.toml +++ b/helix-lsp/Cargo.toml @@ -16,6 +16,7 @@ homepage.workspace = true helix-stdx = { path = "../helix-stdx" } helix-core = { path = "../helix-core" } helix-loader = { path = "../helix-loader" } +helix-event = { path = "../helix-event" } helix-lsp-types = { path = "../helix-lsp-types" } anyhow = "1.0" diff --git a/helix-lsp/src/file_event.rs b/helix-lsp/src/file_event.rs index 5e7f8ca6..ccfe4558 100644 --- a/helix-lsp/src/file_event.rs +++ b/helix-lsp/src/file_event.rs @@ -1,14 +1,19 @@ +use std::path::Path; use std::{collections::HashMap, path::PathBuf, sync::Weak}; use globset::{GlobBuilder, GlobSetBuilder}; +use helix_core::file_watcher::{EventType, Events, FileSystemDidChange}; +use helix_event::register_hook; use tokio::sync::mpsc; use crate::{lsp, Client, LanguageServerId}; enum Event { - FileChanged { + /// file written by helix, special cased to not wait on FS + FileWritten { path: PathBuf, }, + FileWatcher(Events), Register { client_id: LanguageServerId, client: Weak<Client>, @@ -54,6 +59,11 @@ impl Handler { pub fn new() -> Self { let (tx, rx) = mpsc::unbounded_channel(); tokio::spawn(Self::run(rx)); + let tx_ = tx.clone(); + register_hook!(move |event: &mut FileSystemDidChange| { + let _ = tx_.send(Event::FileWatcher(event.fs_events.clone())); + Ok(()) + }); Self { tx } } @@ -80,48 +90,72 @@ impl Handler { } pub fn file_changed(&self, path: PathBuf) { - let _ = self.tx.send(Event::FileChanged { path }); + let _ = self.tx.send(Event::FileWritten { path }); } pub fn remove_client(&self, client_id: LanguageServerId) { let _ = self.tx.send(Event::RemoveClient { client_id }); } + fn notify_files<'a>( + state: &mut HashMap<LanguageServerId, ClientState>, + changes: impl Iterator<Item = (&'a Path, lsp::FileChangeType)> + Clone, + ) { + state.retain(|id, client_state| { + let notifications: Vec<_> = changes + .clone() + .filter(|(path, _)| { + client_state + .registered + .values() + .any(|glob| glob.is_match(path)) + }) + .filter_map(|(path, typ)| { + let uri = lsp::Url::from_file_path(path).ok()?; + let event = lsp::FileEvent { uri, typ }; + Some(event) + }) + .collect(); + if notifications.is_empty() { + return false; + } + let Some(client) = client_state.client.upgrade() else { + log::warn!("LSP client was dropped: {id}"); + return false; + }; + log::debug!( + "Sending didChangeWatchedFiles notification to client '{}'", + client.name() + ); + client.did_change_watched_files(notifications); + true + }) + } + async fn run(mut rx: mpsc::UnboundedReceiver<Event>) { let mut state: HashMap<LanguageServerId, ClientState> = HashMap::new(); while let Some(event) = rx.recv().await { match event { - Event::FileChanged { path } => { + Event::FileWatcher(events) => { + Self::notify_files( + &mut state, + events.iter().filter_map(|event| { + let ty = match event.ty { + EventType::Create => lsp::FileChangeType::CREATED, + EventType::Delete => lsp::FileChangeType::DELETED, + EventType::Modified => lsp::FileChangeType::CHANGED, + EventType::Tempfile => return None, + }; + Some((event.path.as_std_path(), ty)) + }), + ); + } + Event::FileWritten { path } => { log::debug!("Received file event for {:?}", &path); - - state.retain(|id, client_state| { - if !client_state - .registered - .values() - .any(|glob| glob.is_match(&path)) - { - return true; - } - let Some(client) = client_state.client.upgrade() else { - log::warn!("LSP client was dropped: {id}"); - return false; - }; - let Ok(uri) = lsp::Url::from_file_path(&path) else { - return true; - }; - log::debug!( - "Sending didChangeWatchedFiles notification to client '{}'", - client.name() - ); - client.did_change_watched_files(vec![lsp::FileEvent { - uri, - // We currently always send the CHANGED state - // since we don't actually have more context at - // the moment. - typ: lsp::FileChangeType::CHANGED, - }]); - true - }); + Self::notify_files( + &mut state, + [(&*path, lsp::FileChangeType::CHANGED)].iter().cloned(), + ); } Event::Register { client_id, diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index f47cec4b..f49c5a89 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -70,7 +70,7 @@ log = "0.4" # File picker nucleo.workspace = true -ignore = "0.4" +ignore.workspace = true # markdown doc rendering pulldown-cmark = { version = "0.13", default-features = false } # file type detection diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 4831b938..cf052be2 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -1400,11 +1400,13 @@ fn reload(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyh doc.reload(view, &cx.editor.diff_providers).map(|_| { view.ensure_cursor_in_view(doc, scrolloff); })?; - if let Some(path) = doc.path() { - cx.editor - .language_servers - .file_event_handler - .file_changed(path.clone()); + if !cfg!(any(target_os = "linux", target_os = "android")) { + if let Some(path) = doc.path() { + cx.editor + .language_servers + .file_event_handler + .file_changed(path.clone()); + } } Ok(()) } @@ -1446,11 +1448,13 @@ fn reload_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> continue; } - if let Some(path) = doc.path() { - cx.editor - .language_servers - .file_event_handler - .file_changed(path.clone()); + if !cfg!(any(target_os = "linux", target_os = "android")) { + if let Some(path) = doc.path() { + cx.editor + .language_servers + .file_event_handler + .file_changed(path.clone()); + } } for view_id in view_ids { diff --git a/helix-term/src/events.rs b/helix-term/src/events.rs index b0a42298..d25a271e 100644 --- a/helix-term/src/events.rs +++ b/helix-term/src/events.rs @@ -1,3 +1,4 @@ +use helix_core::file_watcher::FileSystemDidChange; use helix_event::{events, register_event}; use helix_view::document::Mode; use helix_view::events::{ @@ -27,4 +28,5 @@ pub fn register() { register_event::<LanguageServerInitialized>(); register_event::<LanguageServerExited>(); register_event::<ConfigDidChange>(); + register_event::<FileSystemDidChange>(); } diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs index 18297bfe..3d72505b 100644 --- a/helix-term/src/handlers.rs +++ b/helix-term/src/handlers.rs @@ -14,6 +14,7 @@ pub use helix_view::handlers::{word_index, Handlers}; use self::document_colors::DocumentColorsHandler; +mod auto_reload; mod auto_save; pub mod completion; pub mod diagnostics; @@ -25,7 +26,7 @@ mod snippet; pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers { events::register(); - let event_tx = completion::CompletionHandler::new(config).spawn(); + let event_tx = completion::CompletionHandler::new(config.clone()).spawn(); let signature_hints = SignatureHelpHandler::new().spawn(); let auto_save = AutoSaveHandler::new().spawn(); let document_colors = DocumentColorsHandler::default().spawn(); @@ -51,5 +52,6 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers { snippet::register_hooks(&handlers); document_colors::register_hooks(&handlers); prompt::register_hooks(&handlers); + auto_reload::register_hooks(&config.load().editor); handlers } diff --git a/helix-term/src/handlers/auto_reload.rs b/helix-term/src/handlers/auto_reload.rs new file mode 100644 index 00000000..df1b28f6 --- /dev/null +++ b/helix-term/src/handlers/auto_reload.rs @@ -0,0 +1,104 @@ +use std::io; +use std::sync::atomic::{self, AtomicBool}; +use std::sync::Arc; +use std::time::SystemTime; + +use helix_core::file_watcher::{EventType, FileSystemDidChange}; +use helix_event::register_hook; +use helix_view::editor::Config; +use helix_view::events::ConfigDidChange; + +use crate::job; + +struct AutoReload { + enable: AtomicBool, + prompt_if_modified: AtomicBool, +} + +impl AutoReload { + pub fn refresh_config(&self, config: &Config) { + self.enable + .store(config.file_watcher.enable, atomic::Ordering::Relaxed); + self.prompt_if_modified + .store(config.file_watcher.enable, atomic::Ordering::Relaxed); + } + + fn on_file_did_change(&self, event: &mut FileSystemDidChange) { + if !self.enable.load(atomic::Ordering::Relaxed) { + return; + } + let fs_events = event.fs_events.clone(); + if !fs_events + .iter() + .any(|event| event.ty == EventType::Modified) + { + return; + } + job::dispatch_blocking(move |editor, _| { + let config = editor.config(); + for fs_event in &*fs_events { + if fs_event.ty != EventType::Modified { + continue; + } + let Some(doc_id) = editor.document_id_by_path(fs_event.path.as_std_path()) else { + return; + }; + let doc = doc_mut!(editor, &doc_id); + let mtime = match doc.path().unwrap().metadata() { + Ok(meta) => meta.modified().unwrap_or(SystemTime::now()), + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Err(_) => SystemTime::now(), + }; + if mtime == doc.last_saved_time { + continue; + } + if doc.is_modified() { + let msg = format!( + "{} auto-reload failed due to unsaved changes, use :reload to refresh", + doc.relative_path().unwrap().display() + ); + editor.set_warning(msg); + } else { + let scrolloff = config.scrolloff; + let view = view_mut!(editor); + match doc.reload(view, &editor.diff_providers) { + Ok(_) => { + view.ensure_cursor_in_view(doc, scrolloff); + let msg = format!( + "{} auto-reload external changes", + doc.relative_path().unwrap().display() + ); + editor.set_status(msg); + } + Err(err) => { + let doc = doc!(editor, &doc_id); + let msg = format!( + "{} auto-reload failed: {err}", + doc.relative_path().unwrap().display() + ); + editor.set_error(msg); + } + } + } + } + }); + } +} + +pub(super) fn register_hooks(config: &Config) { + let handler = Arc::new(AutoReload { + enable: config.auto_reload.enable.into(), + prompt_if_modified: config.auto_reload.prompt_if_modified.into(), + }); + let handler_ = handler.clone(); + register_hook!(move |event: &mut ConfigDidChange<'_>| { + // when a document is initially opened, request colors for it + handler_.refresh_config(event.new); + Ok(()) + }); + register_hook!(move |event: &mut FileSystemDidChange| { + // when a document is initially opened, request colors for it + handler.on_file_did_change(event); + Ok(()) + }); +} diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index b25af107..14c12aec 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -1569,6 +1569,8 @@ impl Component for EditorView { use helix_view::editor::Severity; let style = if *severity == Severity::Error { cx.editor.theme.get("error") + } else if *severity == Severity::Warning { + cx.editor.theme.get("warning") } else { cx.editor.theme.get("ui.text") }; diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index e52dbe0f..b0044d26 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -187,7 +187,7 @@ pub struct Document { // Last time we wrote to the file. This will carry the time the file was last opened if there // were no saves. - last_saved_time: SystemTime, + pub last_saved_time: SystemTime, last_saved_revision: usize, version: i32, // should be usize? diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 7f8cff9c..5c8d2542 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -45,6 +45,7 @@ pub use helix_core::diagnostic::Severity; use helix_core::{ auto_pairs::AutoPairs, diagnostic::DiagnosticProvider, + file_watcher::{self, Watcher}, syntax::{ self, config::{AutoPairConfig, IndentationHeuristic, LanguageServerFeature, SoftWrap}, @@ -427,6 +428,8 @@ pub struct Config { pub rainbow_brackets: bool, /// Whether to enable Kitty Keyboard Protocol pub kitty_keyboard_protocol: KittyKeyboardProtocolConfig, + pub auto_reload: AutoReloadConfig, + pub file_watcher: file_watcher::Config, } #[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, Clone, Copy)] @@ -440,6 +443,22 @@ pub enum KittyKeyboardProtocolConfig { #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)] +pub struct AutoReloadConfig { + pub enable: bool, + pub prompt_if_modified: bool, +} + +impl Default for AutoReloadConfig { + fn default() -> Self { + AutoReloadConfig { + enable: true, + prompt_if_modified: false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)] +#[serde(default, rename_all = "kebab-case", deny_unknown_fields)] pub struct SmartTabConfig { pub enable: bool, pub supersede_menu: bool, @@ -1118,6 +1137,8 @@ impl Default for Config { editor_config: true, rainbow_brackets: false, kitty_keyboard_protocol: Default::default(), + file_watcher: file_watcher::Config::default(), + auto_reload: AutoReloadConfig::default(), } } } @@ -1219,6 +1240,7 @@ pub struct Editor { pub mouse_down_range: Option<Range>, pub cursor_cache: CursorCache, + pub file_watcher: Watcher, } pub type Motion = Box<dyn Fn(&mut Editor)>; @@ -1340,6 +1362,7 @@ impl Editor { handlers, mouse_down_range: None, cursor_cache: CursorCache::default(), + file_watcher: Watcher::new(&conf.file_watcher), } } @@ -1544,12 +1567,15 @@ impl Editor { } ls.did_rename(old_path, &new_path, is_dir); } - self.language_servers - .file_event_handler - .file_changed(old_path.to_owned()); - self.language_servers - .file_event_handler - .file_changed(new_path); + + if !cfg!(any(target_os = "linux", target_os = "android")) { + self.language_servers + .file_event_handler + .file_changed(old_path.to_owned()); + self.language_servers + .file_event_handler + .file_changed(new_path); + } Ok(()) } @@ -2018,8 +2044,10 @@ impl Editor { let handler = self.language_servers.file_event_handler.clone(); let future = async move { let res = doc_save_future.await; - if let Ok(event) = &res { - handler.file_changed(event.path.clone()); + if !cfg!(any(target_os = "linux", target_os = "android")) { + if let Ok(event) = &res { + handler.file_changed(event.path.clone()); + } } res }; diff --git a/helix-view/src/handlers.rs b/helix-view/src/handlers.rs index 6f3ad1ed..e2fed147 100644 --- a/helix-view/src/handlers.rs +++ b/helix-view/src/handlers.rs @@ -1,7 +1,8 @@ use completion::{CompletionEvent, CompletionHandler}; -use helix_event::send_blocking; +use helix_event::{register_hook, send_blocking}; use tokio::sync::mpsc::Sender; +use crate::events::ConfigDidChange; use crate::handlers::lsp::SignatureHelpInvoked; use crate::{DocumentId, Editor, ViewId}; @@ -59,4 +60,9 @@ impl Handlers { pub fn register_hooks(handlers: &Handlers) { lsp::register_hooks(handlers); word_index::register_hooks(handlers); + // must be done here because the file watcher is in helix-core + register_hook!(move |event: &mut ConfigDidChange<'_>| { + event.editor.file_watcher.reload(&event.new.file_watcher); + Ok(()) + }); } diff --git a/helix-view/src/handlers/lsp.rs b/helix-view/src/handlers/lsp.rs index 96ab4626..771d4b3b 100644 --- a/helix-view/src/handlers/lsp.rs +++ b/helix-view/src/handlers/lsp.rs @@ -248,9 +248,11 @@ impl Editor { } fs::write(path, [])?; - self.language_servers - .file_event_handler - .file_changed(path.to_path_buf()); + if !cfg!(any(target_os = "linux", target_os = "android")) { + self.language_servers + .file_event_handler + .file_changed(path.to_path_buf()); + } } } ResourceOp::Delete(op) => { @@ -268,11 +270,13 @@ impl Editor { } else { fs::remove_dir(path)? } - self.language_servers - .file_event_handler - .file_changed(path.to_path_buf()); } else if path.is_file() { fs::remove_file(path)?; + if !cfg!(any(target_os = "linux", target_os = "android")) { + self.language_servers + .file_event_handler + .file_changed(path.to_path_buf()); + } } } ResourceOp::Rename(op) => { |