From 104bb7443f16e1c71417e8688d594b89f9ad214f Mon Sep 17 00:00:00 2001 From: Anthony Rubick <68485672+AnthonyMichaelTDM@users.noreply.github.com> Date: Tue, 15 Jul 2025 17:43:18 -0700 Subject: [PATCH 1/4] feat: automatic reload when file changes externally Implements the idea raised by @porridgewithraisins in this comment: https://github.com/helix-editor/helix/issues/1125#issuecomment-3073782827 The implementation was modeled after the implementation of the autosave feature. Here are some example snippets of configuration for this feature: - disable auto-reloading (default) ```toml [editor] auto-reload = false ``` or ```toml [editor.auto-reload] focus-gained = false ``` - auto-reload on focus ```toml [editor] auto-reload = true ``` or ```toml [editor.auto-reload] focus-gained = true ``` - auto-reload at some periodically at time interval (5 seconds in this example) ```toml [editor.auto-reload] periodic.enable = true periodic.interval = 5000 ``` - of course, you could have it reload on focus and at an interval too: ```toml [editor.auto-reload] focus-gained = true periodic.enable = true periodic.interval = 5000 ``` --- helix-term/src/commands/typed.rs | 6 +- helix-term/src/handlers.rs | 5 + helix-term/src/handlers/auto_reload.rs | 170 +++++++++++++++++++++++++ helix-term/src/ui/editor.rs | 6 + helix-view/src/document.rs | 5 + helix-view/src/editor.rs | 52 ++++++++ helix-view/src/handlers.rs | 8 ++ 7 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 helix-term/src/handlers/auto_reload.rs diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 82cad8386..2f04bf827 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -1406,7 +1406,11 @@ fn reload(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyh Ok(()) } -fn reload_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { +pub fn reload_all( + cx: &mut compositor::Context, + _args: Args, + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs index c7d71526c..bdd403120 100644 --- a/helix-term/src/handlers.rs +++ b/helix-term/src/handlers.rs @@ -5,6 +5,7 @@ use helix_event::AsyncHook; use crate::config::Config; use crate::events; +use crate::handlers::auto_reload::AutoReloadHandler; use crate::handlers::auto_save::AutoSaveHandler; use crate::handlers::signature_help::SignatureHelpHandler; @@ -12,6 +13,7 @@ pub use helix_view::handlers::Handlers; use self::document_colors::DocumentColorsHandler; +mod auto_reload; mod auto_save; pub mod completion; mod diagnostics; @@ -25,12 +27,14 @@ pub fn setup(config: Arc>) -> Handlers { let event_tx = completion::CompletionHandler::new(config).spawn(); let signature_hints = SignatureHelpHandler::new().spawn(); let auto_save = AutoSaveHandler::new().spawn(); + let auto_read = AutoReloadHandler::new().spawn(); let document_colors = DocumentColorsHandler::default().spawn(); let handlers = Handlers { completions: helix_view::handlers::completion::CompletionHandler::new(event_tx), signature_hints, auto_save, + auto_reload: auto_read, document_colors, }; @@ -38,6 +42,7 @@ pub fn setup(config: Arc>) -> Handlers { completion::register_hooks(&handlers); signature_help::register_hooks(&handlers); auto_save::register_hooks(&handlers); + auto_reload::register_hooks(&handlers); diagnostics::register_hooks(&handlers); snippet::register_hooks(&handlers); document_colors::register_hooks(&handlers); diff --git a/helix-term/src/handlers/auto_reload.rs b/helix-term/src/handlers/auto_reload.rs new file mode 100644 index 000000000..3763aa1b0 --- /dev/null +++ b/helix-term/src/handlers/auto_reload.rs @@ -0,0 +1,170 @@ +use std::borrow::Cow; +use std::fs; +use std::sync::atomic::{self, AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use helix_core::command_line::Args; +use helix_event::{register_hook, send_blocking}; +use helix_view::document::Mode; +use helix_view::events::DocumentDidOpen; +use helix_view::handlers::{AutoReloadEvent, Handlers}; +use helix_view::{Document, Editor}; +use tokio::time::Instant; + +use crate::compositor::Compositor; +use crate::events::OnModeSwitch; +use crate::job; +use crate::ui::{Prompt, PromptEvent}; +use crate::{commands, ui}; + +#[derive(Debug)] +pub(super) struct AutoReloadHandler { + reload_pending: Arc, +} + +impl AutoReloadHandler { + pub fn new() -> AutoReloadHandler { + AutoReloadHandler { + reload_pending: Default::default(), + } + } +} + +impl helix_event::AsyncHook for AutoReloadHandler { + type Event = AutoReloadEvent; + + fn handle_event( + &mut self, + event: Self::Event, + existing_debounce: Option, + ) -> Option { + match event { + Self::Event::CheckForChanges { after } => { + Some(Instant::now() + Duration::from_millis(after)) + } + Self::Event::LeftInsertMode | Self::Event::EditorFocused => { + if existing_debounce.is_some() { + // If the event happened more recently than the debounce, let the + // debounce run down before checking for changes. + existing_debounce + } else { + // Otherwise if there is a reload pending, check immediately. + if self.reload_pending.load(Ordering::Relaxed) { + self.finish_debounce(); + } + None + } + } + } + } + + fn finish_debounce(&mut self) { + let reload_pending = self.reload_pending.clone(); + job::dispatch_blocking(move |editor, compositor| { + if editor.mode() == Mode::Insert { + // Avoid reloading while in insert mode since this mixes up + // the modification indicator and prevents future saves. + reload_pending.store(true, atomic::Ordering::Relaxed); + } else { + prompt_to_reload_if_needed(editor, compositor); + reload_pending.store(false, atomic::Ordering::Relaxed); + } + }); + } +} + +/// Requests a reload if any documents have been modified externally. +fn prompt_to_reload_if_needed(editor: &mut Editor, compositor: &mut Compositor) { + let modified_docs = editor + .documents() + // Filter out documents that have unsaved changes. + .filter(|doc| !doc.is_modified()) + // Get the documents that have been modified externally. + .filter(has_document_been_externally_modified) + .count(); + + // If there are no externally modified documents, we can do nothing. + if modified_docs == 0 { + // Reset the debounce timer to allow for the next check. + let config = editor.config.load(); + if config.auto_reload.periodic.enable { + let interval = config.auto_reload.periodic.interval; + send_blocking( + &editor.handlers.auto_reload, + AutoReloadEvent::CheckForChanges { after: interval }, + ); + } + + return; + } + + let prompt = Prompt::new( + Cow::Borrowed("Some files have been modified externally, press Enter to reload them."), + None, + ui::completers::none, + |cx, _, event| { + if event == PromptEvent::Update { + return; + } + + if let Err(err) = + commands::typed::reload_all(cx, Args::default(), PromptEvent::Validate) + { + cx.editor + .set_error(format!("Failed to reload document: {err}")); + } else { + cx.editor.set_status("Reloaded modified documents"); + } + + // Reset the debounce timer to allow for the next check. + let config = cx.editor.config.load(); + if config.auto_reload.periodic.enable { + let interval = config.auto_reload.periodic.interval; + send_blocking( + &cx.editor.handlers.auto_reload, + AutoReloadEvent::CheckForChanges { after: interval }, + ); + } + }, + ); + // Show the prompt to the user. + compositor.push(Box::new(prompt)); +} + +fn has_document_been_externally_modified(doc: &&Document) -> bool { + let last_saved_time = doc.get_last_saved_time(); + let Some(path) = doc.path() else { + return false; + }; + + // Check if the file has been modified externally + if let Ok(metadata) = fs::metadata(path) { + if let Ok(modified_time) = metadata.modified() { + if modified_time > last_saved_time { + return true; + } + } + } + false +} + +pub(super) fn register_hooks(handlers: &Handlers) { + let tx = handlers.auto_reload.clone(); + register_hook!(move |event: &mut DocumentDidOpen<'_>| { + let config = event.editor.config.load(); + if config.auto_reload.periodic.enable { + let interval = config.auto_reload.periodic.interval; + send_blocking(&tx, AutoReloadEvent::CheckForChanges { after: interval }); + } + Ok(()) + }); + + let tx = handlers.auto_reload.clone(); + register_hook!(move |event: &mut OnModeSwitch<'_, '_>| { + if event.old_mode == Mode::Insert { + send_blocking(&tx, AutoReloadEvent::LeftInsertMode) + } + Ok(()) + }); +} diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 9343d55d4..60d65c33b 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -1468,6 +1468,12 @@ impl Component for EditorView { Event::Mouse(event) => self.handle_mouse_event(event, &mut cx), Event::IdleTimeout => self.handle_idle_timeout(&mut cx), Event::FocusGained => { + if context.editor.config().auto_reload.focus_gained { + helix_event::send_blocking( + &context.editor.handlers.auto_reload, + helix_view::handlers::AutoReloadEvent::EditorFocused, + ); + } self.terminal_focused = true; EventResult::Consumed(None) } diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 2fff23471..8b6f6e3cc 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -1200,6 +1200,11 @@ impl Document { }; } + /// Return the last saved time of the document. + pub fn get_last_saved_time(&self) -> SystemTime { + self.last_saved_time + } + // Detect if the file is readonly and change the readonly field if necessary (unix only) pub fn detect_readonly(&mut self) { // Allows setting the flag for files the user cannot modify, like root files diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 27dc45235..3572e22d9 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -63,6 +63,7 @@ use arc_swap::{ }; pub const DEFAULT_AUTO_SAVE_DELAY: u64 = 3000; +pub const DEFAULT_AUTO_RELOAD_INTERVAL: u64 = 3000; fn deserialize_duration_millis<'de, D>(deserializer: D) -> Result where @@ -287,6 +288,10 @@ pub struct Config { /// Time delay defaults to false with 3000ms delay. Focus lost defaults to false. #[serde(deserialize_with = "deserialize_auto_save")] pub auto_save: AutoSave, + /// Automatic reload of the modified documents on a periodic time interval and/or when the editor gains focus. + /// Time interval defaults to false with 3000ms delay. Focus gained defaults to false. + #[serde(deserialize_with = "deserialize_auto_reload")] + pub auto_reload: AutoReload, /// Set a global text_width pub text_width: usize, /// Time in milliseconds since last keypress before idle timers trigger. @@ -883,6 +888,52 @@ where } } +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct AutoReload { + /// Whether to check for file changes when the editor is focused. Defaults to false. + #[serde(default)] + pub focus_gained: bool, + /// Autosave periodically at some interval. Defaults to disabled. + #[serde(default)] + pub periodic: AutoReloadPeriodic, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AutoReloadPeriodic { + #[serde(default)] + /// Enable auto reload periodically. Defaults to false. + pub enable: bool, + #[serde(default = "default_auto_read_interval")] + /// Time interval in milliseconds. Defaults to [DEFAULT_AUTO_READ_INTERVAL]. + pub interval: u64, +} + +pub fn default_auto_read_interval() -> u64 { + DEFAULT_AUTO_RELOAD_INTERVAL +} + +fn deserialize_auto_reload<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize, Serialize)] + #[serde(untagged, deny_unknown_fields, rename_all = "kebab-case")] + enum AutoReloadToml { + FocusGained(bool), + AutoReload(AutoReload), + } + + match AutoReloadToml::deserialize(deserializer)? { + AutoReloadToml::FocusGained(focus_gained) => Ok(AutoReload { + focus_gained, + ..Default::default() + }), + AutoReloadToml::AutoReload(auto_read) => Ok(auto_read), + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct WhitespaceCharacters { @@ -996,6 +1047,7 @@ impl Default for Config { auto_format: true, default_yank_register: '"', auto_save: AutoSave::default(), + auto_reload: AutoReload::default(), idle_timeout: Duration::from_millis(250), completion_timeout: Duration::from_millis(250), preview_completion_insert: true, diff --git a/helix-view/src/handlers.rs b/helix-view/src/handlers.rs index 258ed89e5..6f4a01dae 100644 --- a/helix-view/src/handlers.rs +++ b/helix-view/src/handlers.rs @@ -16,11 +16,19 @@ pub enum AutoSaveEvent { LeftInsertMode, } +#[derive(Debug)] +pub enum AutoReloadEvent { + CheckForChanges { after: u64 }, + EditorFocused, + LeftInsertMode, +} + pub struct Handlers { // only public because most of the actual implementation is in helix-term right now :/ pub completions: CompletionHandler, pub signature_hints: Sender, pub auto_save: Sender, + pub auto_reload: Sender, pub document_colors: Sender, } From bf02b4dbea72e5e9bad71eaadd2abcae072dc56d Mon Sep 17 00:00:00 2001 From: Anthony Rubick <68485672+AnthonyMichaelTDM@users.noreply.github.com> Date: Tue, 15 Jul 2025 17:56:38 -0700 Subject: [PATCH 2/4] fix: typos I has originally named this feature `auto_read`, but changed the name to `auto_reload`, this changes some references I forgot to update to the new name --- helix-term/src/handlers.rs | 4 ++-- helix-view/src/editor.rs | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs index bdd403120..b41c8ea83 100644 --- a/helix-term/src/handlers.rs +++ b/helix-term/src/handlers.rs @@ -27,14 +27,14 @@ pub fn setup(config: Arc>) -> Handlers { let event_tx = completion::CompletionHandler::new(config).spawn(); let signature_hints = SignatureHelpHandler::new().spawn(); let auto_save = AutoSaveHandler::new().spawn(); - let auto_read = AutoReloadHandler::new().spawn(); + let auto_reload = AutoReloadHandler::new().spawn(); let document_colors = DocumentColorsHandler::default().spawn(); let handlers = Handlers { completions: helix_view::handlers::completion::CompletionHandler::new(event_tx), signature_hints, auto_save, - auto_reload: auto_read, + auto_reload, document_colors, }; diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 3572e22d9..b3b5d374b 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -905,12 +905,12 @@ pub struct AutoReloadPeriodic { #[serde(default)] /// Enable auto reload periodically. Defaults to false. pub enable: bool, - #[serde(default = "default_auto_read_interval")] - /// Time interval in milliseconds. Defaults to [DEFAULT_AUTO_READ_INTERVAL]. + #[serde(default = "default_auto_reload_interval")] + /// Time interval in milliseconds. Defaults to [DEFAULT_AUTO_RELOAD_INTERVAL]. pub interval: u64, } -pub fn default_auto_read_interval() -> u64 { +pub fn default_auto_reload_interval() -> u64 { DEFAULT_AUTO_RELOAD_INTERVAL } @@ -930,7 +930,7 @@ where focus_gained, ..Default::default() }), - AutoReloadToml::AutoReload(auto_read) => Ok(auto_read), + AutoReloadToml::AutoReload(auto_reload) => Ok(auto_reload), } } From 1d27b503188ebd5ae0ac32bbafa198048cfcf2a4 Mon Sep 17 00:00:00 2001 From: Anthony Rubick <68485672+AnthonyMichaelTDM@users.noreply.github.com> Date: Tue, 15 Jul 2025 18:28:01 -0700 Subject: [PATCH 3/4] fix: auto-reloading on focus Open question: should we prompt users here too? currently don't --- helix-term/src/handlers.rs | 2 +- helix-term/src/handlers/auto_reload.rs | 42 ++++++++++++-------------- helix-term/src/ui/editor.rs | 20 +++++++++--- 3 files changed, 37 insertions(+), 27 deletions(-) diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs index b41c8ea83..41d85f789 100644 --- a/helix-term/src/handlers.rs +++ b/helix-term/src/handlers.rs @@ -13,7 +13,7 @@ pub use helix_view::handlers::Handlers; use self::document_colors::DocumentColorsHandler; -mod auto_reload; +pub(super) mod auto_reload; mod auto_save; pub mod completion; mod diagnostics; diff --git a/helix-term/src/handlers/auto_reload.rs b/helix-term/src/handlers/auto_reload.rs index 3763aa1b0..f111bc1d7 100644 --- a/helix-term/src/handlers/auto_reload.rs +++ b/helix-term/src/handlers/auto_reload.rs @@ -76,16 +76,8 @@ impl helix_event::AsyncHook for AutoReloadHandler { /// Requests a reload if any documents have been modified externally. fn prompt_to_reload_if_needed(editor: &mut Editor, compositor: &mut Compositor) { - let modified_docs = editor - .documents() - // Filter out documents that have unsaved changes. - .filter(|doc| !doc.is_modified()) - // Get the documents that have been modified externally. - .filter(has_document_been_externally_modified) - .count(); - // If there are no externally modified documents, we can do nothing. - if modified_docs == 0 { + if count_externally_modified_documents(editor.documents()) == 0 { // Reset the debounce timer to allow for the next check. let config = editor.config.load(); if config.auto_reload.periodic.enable { @@ -132,21 +124,27 @@ fn prompt_to_reload_if_needed(editor: &mut Editor, compositor: &mut Compositor) compositor.push(Box::new(prompt)); } -fn has_document_been_externally_modified(doc: &&Document) -> bool { - let last_saved_time = doc.get_last_saved_time(); - let Some(path) = doc.path() else { - return false; - }; +pub fn count_externally_modified_documents<'a>(docs: impl Iterator) -> usize { + docs // Filter out documents that have unsaved changes. + .filter(|doc| !doc.is_modified()) + // Get the documents that have been modified externally. + .filter(|doc| { + let last_saved_time = doc.get_last_saved_time(); + let Some(path) = doc.path() else { + return false; + }; - // Check if the file has been modified externally - if let Ok(metadata) = fs::metadata(path) { - if let Ok(modified_time) = metadata.modified() { - if modified_time > last_saved_time { - return true; + // Check if the file has been modified externally + if let Ok(metadata) = fs::metadata(path) { + if let Ok(modified_time) = metadata.modified() { + if modified_time > last_saved_time { + return true; + } + } } - } - } - false + false + }) + .count() } pub(super) fn register_hooks(handlers: &Handlers) { diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 60d65c33b..bbf7d55e7 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -1469,10 +1469,22 @@ impl Component for EditorView { Event::IdleTimeout => self.handle_idle_timeout(&mut cx), Event::FocusGained => { if context.editor.config().auto_reload.focus_gained { - helix_event::send_blocking( - &context.editor.handlers.auto_reload, - helix_view::handlers::AutoReloadEvent::EditorFocused, - ); + if crate::handlers::auto_reload::count_externally_modified_documents( + context.editor.documents(), + ) > 0 + { + if let Err(e) = commands::typed::reload_all( + context, + helix_core::command_line::Args::default(), + super::PromptEvent::Validate, + ) { + context.editor.set_error(format!("{}", e)); + } else { + context + .editor + .set_status("Reloaded files due to external changes"); + } + } } self.terminal_focused = true; EventResult::Consumed(None) From 0df5f5f446b3aef88c0db447b0b90df442652f52 Mon Sep 17 00:00:00 2001 From: Anthony Rubick <68485672+AnthonyMichaelTDM@users.noreply.github.com> Date: Tue, 15 Jul 2025 18:54:23 -0700 Subject: [PATCH 4/4] doc(auto-reloading): document configuration of auto-reloading --- book/src/editor.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/book/src/editor.md b/book/src/editor.md index b264201f5..88b791a7e 100644 --- a/book/src/editor.md +++ b/book/src/editor.md @@ -7,6 +7,7 @@ - [`[editor.cursor-shape]` Section](#editorcursor-shape-section) - [`[editor.file-picker]` Section](#editorfile-picker-section) - [`[editor.auto-pairs]` Section](#editorauto-pairs-section) +- [`[editor.auto-reload]` Section](#editorauto-reload-section) - [`[editor.auto-save]` Section](#editorauto-save-section) - [`[editor.search]` Section](#editorsearch-section) - [`[editor.whitespace]` Section](#editorwhitespace-section) @@ -264,6 +265,16 @@ name = "rust" '<' = '>' ``` +### `[editor.auto-reload]` Section + +Controls auto reloading of externally modified files. + +| Key | Description | Default | +|--|--|---------| +| `focus-gained` | Enable automatic reloading of externally modified files when Helix is focused. Requires [focus event support](https://github.com/helix-editor/helix/wiki/Terminal-Support) from your terminal | `false` | +| `periodic.enable` | Enable periodic auto reloading of externally modified files | `false` | +| `periodic.interval` | Time interval in milliseconds between auto reload checks | `3000` | + ### `[editor.auto-save]` Section Control auto save behavior.