mirror of
https://github.com/helix-editor/helix.git
synced 2025-10-06 00:13:28 +02:00
Compare commits
1 Commits
syntax-sym
...
filesentry
Author | SHA1 | Date | |
---|---|---|---|
|
f05f36d846 |
59
Cargo.lock
generated
59
Cargo.lock
generated
@@ -338,6 +338,12 @@ version = "1.0.5"
|
||||
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"
|
||||
@@ -437,6 +443,24 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[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.4",
|
||||
"ignore",
|
||||
"log",
|
||||
"memchr",
|
||||
"mio",
|
||||
"papaya",
|
||||
"rustix 1.0.7",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.25"
|
||||
@@ -1404,11 +1428,14 @@ dependencies = [
|
||||
"bitflags",
|
||||
"chrono",
|
||||
"encoding_rs",
|
||||
"filesentry",
|
||||
"foldhash",
|
||||
"globset",
|
||||
"helix-event",
|
||||
"helix-loader",
|
||||
"helix-parsec",
|
||||
"helix-stdx",
|
||||
"ignore",
|
||||
"imara-diff 0.2.0",
|
||||
"indoc",
|
||||
"log",
|
||||
@@ -1493,6 +1520,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"globset",
|
||||
"helix-core",
|
||||
"helix-event",
|
||||
"helix-loader",
|
||||
"helix-lsp-types",
|
||||
"helix-stdx",
|
||||
@@ -2045,9 +2073,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.4"
|
||||
version = "2.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
||||
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
@@ -2078,15 +2106,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]]
|
||||
@@ -2155,6 +2182,16 @@ dependencies = [
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -2417,6 +2454,16 @@ version = "1.2.0"
|
||||
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.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.219"
|
||||
|
@@ -50,6 +50,7 @@ parking_lot = "0.12"
|
||||
futures-executor = "0.3"
|
||||
futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false }
|
||||
tokio-stream = "0.1.17"
|
||||
ignore = "0.4"
|
||||
|
||||
[workspace.package]
|
||||
version = "25.1.1"
|
||||
|
@@ -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"
|
||||
|
517
helix-core/src/file_watcher.rs
Normal file
517
helix-core/src/file_watcher.rs
Normal file
@@ -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")));
|
||||
}
|
||||
}
|
@@ -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;
|
||||
|
@@ -9,6 +9,7 @@ pub use types::*;
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
|
||||
use thiserror::Error;
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
|
@@ -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"
|
||||
|
@@ -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,
|
||||
|
@@ -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
|
||||
|
@@ -1332,11 +1332,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(())
|
||||
}
|
||||
@@ -1378,11 +1380,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 {
|
||||
|
@@ -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>();
|
||||
}
|
||||
|
@@ -12,6 +12,7 @@ pub use helix_view::handlers::Handlers;
|
||||
|
||||
use self::document_colors::DocumentColorsHandler;
|
||||
|
||||
mod auto_reload;
|
||||
mod auto_save;
|
||||
pub mod completion;
|
||||
mod diagnostics;
|
||||
@@ -22,7 +23,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();
|
||||
@@ -41,5 +42,6 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
|
||||
diagnostics::register_hooks(&handlers);
|
||||
snippet::register_hooks(&handlers);
|
||||
document_colors::register_hooks(&handlers);
|
||||
auto_reload::register_hooks(&config.load().editor);
|
||||
handlers
|
||||
}
|
||||
|
104
helix-term/src/handlers/auto_reload.rs
Normal file
104
helix-term/src/handlers/auto_reload.rs
Normal file
@@ -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(())
|
||||
});
|
||||
}
|
@@ -1535,6 +1535,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")
|
||||
};
|
||||
|
@@ -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?
|
||||
|
@@ -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},
|
||||
@@ -372,6 +373,24 @@ pub struct Config {
|
||||
/// Whether to read settings from [EditorConfig](https://editorconfig.org) files. Defaults to
|
||||
/// `true`.
|
||||
pub editor_config: bool,
|
||||
pub auto_reload: AutoReloadConfig,
|
||||
pub file_watcher: file_watcher::Config,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
@@ -1030,6 +1049,8 @@ impl Default for Config {
|
||||
end_of_line_diagnostics: DiagnosticFilter::Disable,
|
||||
clipboard_provider: ClipboardProvider::default(),
|
||||
editor_config: true,
|
||||
file_watcher: file_watcher::Config::default(),
|
||||
auto_reload: AutoReloadConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1131,6 +1152,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)>;
|
||||
@@ -1252,6 +1274,7 @@ impl Editor {
|
||||
handlers,
|
||||
mouse_down_range: None,
|
||||
cursor_cache: CursorCache::default(),
|
||||
file_watcher: Watcher::new(&conf.file_watcher),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1456,12 +1479,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(())
|
||||
}
|
||||
|
||||
@@ -1930,8 +1956,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
|
||||
};
|
||||
|
@@ -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};
|
||||
|
||||
@@ -50,4 +51,9 @@ impl Handlers {
|
||||
|
||||
pub fn register_hooks(handlers: &Handlers) {
|
||||
lsp::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(())
|
||||
});
|
||||
}
|
||||
|
@@ -239,9 +239,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) => {
|
||||
@@ -259,11 +261,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) => {
|
||||
|
Reference in New Issue
Block a user