Compare commits

...

1 Commits

Author SHA1 Message Date
Pascal Kuthe
f05f36d846 implement file-watching based on filesentry 2025-10-05 22:08:18 +02:00
18 changed files with 823 additions and 66 deletions

59
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View 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")));
}
}

View File

@@ -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;

View File

@@ -9,6 +9,7 @@ pub use types::*;
use serde::de::DeserializeOwned;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {

View File

@@ -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"

View File

@@ -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,

View File

@@ -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

View File

@@ -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 {

View File

@@ -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>();
}

View File

@@ -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
}

View 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(())
});
}

View File

@@ -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")
};

View File

@@ -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?

View File

@@ -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
};

View File

@@ -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(())
});
}

View File

@@ -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) => {