Unnamed repository; edit this file 'description' to name the repository.
implement file-watching based on filesentry
Pascal Kuthe 5 months ago
parent 6fffaf6 · commit 619e32d
-rw-r--r--Cargo.lock65
-rw-r--r--Cargo.toml1
-rw-r--r--helix-core/Cargo.toml3
-rw-r--r--helix-core/src/file_watcher.rs517
-rw-r--r--helix-core/src/lib.rs1
-rw-r--r--helix-dap/src/lib.rs1
-rw-r--r--helix-lsp/Cargo.toml1
-rw-r--r--helix-lsp/src/file_event.rs98
-rw-r--r--helix-term/Cargo.toml2
-rw-r--r--helix-term/src/commands/typed.rs24
-rw-r--r--helix-term/src/events.rs2
-rw-r--r--helix-term/src/handlers.rs4
-rw-r--r--helix-term/src/handlers/auto_reload.rs104
-rw-r--r--helix-term/src/ui/editor.rs2
-rw-r--r--helix-view/src/document.rs2
-rw-r--r--helix-view/src/editor.rs44
-rw-r--r--helix-view/src/handlers.rs8
-rw-r--r--helix-view/src/handlers/lsp.rs16
18 files changed, 826 insertions, 69 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 9c7b7ad3..2d6165e7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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]]
diff --git a/Cargo.toml b/Cargo.toml
index 0c82cb8a..e6ace7de 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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) => {