feat: reload clean buffers and guard external-change writes

This commit is contained in:
2026-10-12 22:27:08 +02:00
parent bb5c247ab2
commit 298d586c3e
7 changed files with 556 additions and 41 deletions
+434 -31
View File
@@ -14,7 +14,7 @@ use std::fs::{self, OpenOptions};
use std::io::{self, Read, Write};
use std::ops::Range;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -694,6 +694,14 @@ core_actions![
true,
),
("core.save", Action::Save, &[Mode::Normal], true),
(
"core.force_save",
Action::ForceSave,
&[Mode::Normal],
true,
false,
true
),
("core.diff", Action::Diff, &[Mode::Normal], true),
(
"core.word_wrap.toggle",
@@ -734,6 +742,14 @@ core_actions![
&[Mode::Normal],
true
),
(
"core.force_save_and_quit",
Action::ForceSaveAndQuit,
&[Mode::Normal],
true,
false,
true
),
("core.plugins", Action::Plugins, &[Mode::Normal], true),
("core.edit", Action::Edit, &[Mode::Normal], true, true),
("core.buffers", Action::Buffers, &[Mode::Normal], true),
@@ -769,9 +785,11 @@ core_actions![
// Alias targets stay typed; canonical spelling comes from the action catalog.
const COMMAND_ALIASES: &[(&str, Action)] = &[
("w", Action::Save),
("w!", Action::ForceSave),
("q", Action::Quit),
("q!", Action::ForceQuit),
("wq", Action::SaveAndQuit),
("wq!", Action::ForceSaveAndQuit),
("bnext", Action::BufferNext),
("bprev", Action::BufferPrevious),
("bdelete", Action::BufferDelete),
@@ -833,7 +851,9 @@ impl Action {
| Self::PasteSystemAfter
| Self::Newline
| Self::Save
| Self::ForceSave
| Self::SaveAndQuit
| Self::ForceSaveAndQuit
| Self::BufferDelete
| Self::BufferForceDelete
| Self::LspCompletion
@@ -1285,6 +1305,19 @@ impl ViewState {
self.search_match = None;
self.visual_motions = None;
}
fn reload(&mut self, lines: &[String]) {
self.clamp(lines);
self.insert_start = (self.mode == Mode::Insert).then(|| Snapshot {
lines: lines.to_vec(),
targets: self.targets.clone(),
primary: self.primary,
});
self.pending_edit = (self.mode == Mode::Insert).then(|| EditRecipe {
start: EditStart::Insert(Action::Insert),
steps: Vec::new(),
});
}
}
struct Pane {
@@ -1313,6 +1346,41 @@ impl Tab {
}
}
#[derive(Clone, Copy, Eq, PartialEq)]
struct FileStamp {
modified: std::time::SystemTime,
len: u64,
#[cfg(unix)]
identity: (u64, u64, i64, i64),
}
impl FileStamp {
fn from_metadata(metadata: &fs::Metadata) -> io::Result<Self> {
if !metadata.is_file() {
return Err(io::Error::other("File is not a regular file"));
}
Ok(Self {
modified: metadata.modified()?,
len: metadata.len(),
#[cfg(unix)]
identity: (
metadata.dev(),
metadata.ino(),
metadata.ctime(),
metadata.ctime_nsec(),
),
})
}
fn read(path: &Path) -> io::Result<Option<Self>> {
match fs::metadata(path) {
Ok(metadata) => Self::from_metadata(&metadata).map(Some),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
}
struct Buffer {
id: usize,
diff_snapshot: Option<(u64, usize, Focus)>,
@@ -1320,6 +1388,8 @@ struct Buffer {
snapshot_root: Option<PathBuf>,
lines: Vec<String>,
saved_lines: Vec<String>,
disk_stamp: Option<FileStamp>,
reload_error: Option<String>,
text_revision: u64,
save_revision: u64,
diagnostics: BTreeMap<String, AnnotationSet>,
@@ -1339,9 +1409,15 @@ impl Buffer {
identity: Option<PathBuf>,
allow_missing: bool,
) -> io::Result<Self> {
let mut disk_stamp = None;
let text = match &path {
Some(path) => match fs::read_to_string(path) {
Ok(text) => text,
Some(path) => match fs::File::open(path) {
Ok(mut file) => {
disk_stamp = Some(FileStamp::from_metadata(&file.metadata()?)?);
let mut text = String::new();
file.read_to_string(&mut text)?;
text
}
Err(error) if allow_missing && error.kind() == io::ErrorKind::NotFound => {
String::new()
}
@@ -1349,7 +1425,7 @@ impl Buffer {
},
None => String::new(),
};
Self::from_text(id, path, identity, &text)
Self::from_text(id, path, identity, &text, disk_stamp)
}
fn from_text(
@@ -1357,6 +1433,7 @@ impl Buffer {
path: Option<PathBuf>,
identity: Option<PathBuf>,
text: &str,
disk_stamp: Option<FileStamp>,
) -> io::Result<Self> {
validate_text(text).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
let lines = split_lines(text);
@@ -1366,6 +1443,8 @@ impl Buffer {
snapshot_document_path: None,
snapshot_root: None,
saved_lines: lines.clone(),
disk_stamp,
reload_error: None,
lines,
text_revision: 0,
save_revision: 0,
@@ -1386,6 +1465,52 @@ impl Buffer {
self.diff_snapshot.is_none() && self.lines != self.saved_lines
}
fn refresh_file(&mut self) -> io::Result<bool> {
if self.diff_snapshot.is_some() || self.dirty() {
return Ok(false);
}
let Some(path) = &self.path else {
return Ok(false);
};
let stamp = FileStamp::read(path)?;
if stamp == self.disk_stamp {
return Ok(false);
}
let Some(stamp) = stamp else {
return Err(io::Error::new(io::ErrorKind::NotFound, "File was removed"));
};
let mut file = fs::File::open(path)?;
if FileStamp::from_metadata(&file.metadata()?)? != stamp {
return Err(io::Error::new(
io::ErrorKind::WouldBlock,
"File changed while reloading",
));
}
let mut text = String::new();
file.read_to_string(&mut text)?;
if FileStamp::from_metadata(&file.metadata()?)? != stamp {
return Err(io::Error::new(
io::ErrorKind::WouldBlock,
"File changed while reloading",
));
}
validate_text(&text).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
self.disk_stamp = Some(stamp);
if normalize_newlines(&text)
.split('\n')
.eq(self.lines.iter().map(String::as_str))
{
return Ok(false);
}
self.lines = split_lines(&text);
self.saved_lines.clone_from(&self.lines);
self.undo.clear();
self.redo.clear();
self.text_changed();
self.view.reload(&self.lines);
Ok(true)
}
fn info(&self, active: bool) -> BufferInfo<'_> {
BufferInfo {
id: self.id,
@@ -3408,8 +3533,14 @@ impl Editor {
if text.len() > MAX_EDIT_BYTES {
return Err("Definition target exceeds 4 MiB".into());
}
let buffer = Buffer::from_text(self.next_buffer_id, Some(path), Some(identity), &text)
.map_err(|error| error.to_string())?;
let buffer = Buffer::from_text(
self.next_buffer_id,
Some(path),
Some(identity),
&text,
Some(FileStamp::from_metadata(&metadata).map_err(|error| error.to_string())?),
)
.map_err(|error| error.to_string())?;
Ok((Some(buffer), self.next_buffer_id))
}
@@ -5714,9 +5845,11 @@ impl Editor {
| Action::TabClose
| Action::TabForceClose
| Action::Save
| Action::ForceSave
| Action::Quit
| Action::ForceQuit
| Action::SaveAndQuit
| Action::ForceSaveAndQuit
| Action::Plugins
| Action::Edit
| Action::Buffers
@@ -6592,12 +6725,16 @@ impl Editor {
args: String::new(),
});
}
Action::Save | Action::SaveAndQuit => {
if self.save(if args.is_empty() {
None
} else {
Some(PathBuf::from(args))
}) && action == Action::SaveAndQuit
Action::Save | Action::ForceSave | Action::SaveAndQuit | Action::ForceSaveAndQuit => {
let force = matches!(action, Action::ForceSave | Action::ForceSaveAndQuit);
if self.save(
if args.is_empty() {
None
} else {
Some(PathBuf::from(args))
},
force,
) && matches!(action, Action::SaveAndQuit | Action::ForceSaveAndQuit)
{
if self.any_dirty() {
self.set_message("Unsaved changes in other buffers; use :w or :q!");
@@ -6729,7 +6866,57 @@ impl Editor {
Ok(())
}
fn save(&mut self, path: Option<PathBuf>) -> bool {
/// Refreshes every live buffer, including views parked in inactive tabs.
pub(crate) fn refresh_files(&mut self) -> bool {
let active_id = self.active.id;
let mut changed = false;
let mut active_reloaded = false;
let mut notice = None;
for buffer in std::iter::once(&mut self.active).chain(self.parked.values_mut()) {
match buffer.refresh_file() {
Ok(reloaded) => {
buffer.reload_error = None;
if !reloaded {
continue;
}
changed = true;
active_reloaded |= buffer.id == active_id;
for pane in self.panes.values_mut() {
if let Some(view) = pane.views.get_mut(&buffer.id) {
view.reload(&buffer.lines);
}
}
}
Err(error) => {
let message = format!(
"Reload failed for {}: {error}",
buffer.path.as_deref().unwrap().display(),
);
if buffer.reload_error.as_ref() != Some(&message) {
buffer.reload_error = Some(message.clone());
notice = Some(message);
changed = true;
}
}
}
}
if active_reloaded {
self.completion = None;
self.snippet = None;
self.clear_hover();
self.pending_lsp_navigation = None;
self.normalize_targets();
}
if let Some(notice) = notice {
self.set_message(notice);
}
if changed {
self.refresh_live_diff();
}
changed
}
fn save(&mut self, path: Option<PathBuf>, force: bool) -> bool {
if self.mutation_blocked() {
self.set_message("Diff pane is read-only");
return false;
@@ -6756,10 +6943,19 @@ impl Editor {
));
return false;
}
match atomic_save(&path, &self.active.lines) {
Ok(()) => {
let expected = (!force
&& (self.active.identity.as_ref() == Some(&identity)
|| self.active.path.as_ref() == Some(&path)))
.then_some((
self.active.saved_lines.as_slice(),
self.active.disk_stamp.is_some(),
));
match atomic_save(&path, &self.active.lines, expected) {
Ok(stamp) => {
self.active.saved_lines.clone_from(&self.active.lines);
self.active.save_revision = self.active.save_revision.wrapping_add(1);
self.active.disk_stamp = Some(stamp);
self.active.reload_error = None;
if self.active.path.as_ref() != Some(&path) {
self.active.semantic_tokens = None;
}
@@ -9406,8 +9602,38 @@ fn target_metadata(path: &Path) -> io::Result<Option<fs::Metadata>> {
}
}
fn atomic_save(path: &Path, lines: &[String]) -> io::Result<()> {
fn check_disk_contents(path: &Path, expected: Option<(&[String], bool)>) -> io::Result<()> {
let Some((lines, existed)) = expected else {
return Ok(());
};
let matches = match fs::read_to_string(path) {
Ok(text) => {
existed
&& normalize_newlines(&text)
.split('\n')
.eq(lines.iter().map(String::as_str))
}
Err(error) if error.kind() == io::ErrorKind::NotFound => !existed,
Err(error) if error.kind() == io::ErrorKind::InvalidData => false,
Err(error) => return Err(error),
};
if matches {
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"File changed outside vamp; use :w! to overwrite",
))
}
}
fn atomic_save(
path: &Path,
lines: &[String],
expected: Option<(&[String], bool)>,
) -> io::Result<FileStamp> {
let metadata = target_metadata(path)?;
check_disk_contents(path, expected)?;
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
@@ -9436,13 +9662,15 @@ fn atomic_save(path: &Path, lines: &[String]) -> io::Result<()> {
file.set_permissions(metadata.permissions())?;
}
file.sync_all()?;
let stamp = FileStamp::from_metadata(&file.metadata()?)?;
drop(file);
// Recheck immediately before rename: never knowingly replace a symlink.
target_metadata(path)?;
check_disk_contents(path, expected)?;
fs::rename(&temporary, path)?;
#[cfg(unix)]
fs::File::open(parent)?.sync_all()?;
Ok(())
Ok(stamp)
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
@@ -9490,6 +9718,176 @@ mod tests {
}
}
#[test]
fn external_reload_clamps_shared_views_and_restarts_clean_insert_history() {
let directory = Directory::new();
let path = directory.path("file.txt");
fs::write(&path, "one\ntwo\nthree").unwrap();
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
editor
.apply(vec![Effect::Replace("old edit".into())])
.unwrap();
editor.handle(Input::Char('u'));
type_text(&mut editor, "gb$");
editor.execute("tab.new");
editor.handle(Input::Char('|'));
let pane = editor.pane_id();
let revision = editor.text_revision();
fs::write(&path, "é").unwrap();
assert!(editor.refresh_files());
assert_eq!(editor.lines(), ["é"]);
assert!(!editor.dirty());
assert!(editor.text_revision() > revision);
assert_eq!(editor.cursor(), Cursor::default());
editor.handle(Input::Alt('h'));
assert_ne!(editor.pane_id(), pane);
assert_eq!(editor.cursor(), Cursor::default());
editor.execute("tab.previous");
assert_eq!(editor.cursor(), Cursor::default());
editor.handle(Input::Char('U'));
assert_eq!(editor.lines(), ["é"]);
editor.handle(Input::Char('i'));
fs::write(&path, "😀ZZ").unwrap();
assert!(editor.refresh_files());
assert_eq!(editor.mode(), Mode::Insert);
type_text(&mut editor, "X");
editor.handle(Input::Escape);
editor.handle(Input::Char('u'));
assert_eq!(editor.lines(), ["😀ZZ"]);
assert!(!editor.dirty());
}
#[test]
fn external_dirty_writes_require_force_even_without_a_poll() {
for (normal, force, quits) in [
("w", "w!", false),
("save", "force_save", false),
("wq", "wq!", true),
("save_and_quit", "force_save_and_quit", true),
] {
let directory = Directory::new();
let path = directory.path("file.txt");
fs::write(&path, "baseline").unwrap();
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
editor
.apply(vec![Effect::Replace("local edits".into())])
.unwrap();
fs::write(&path, "external").unwrap();
assert!(!editor.refresh_files());
editor.execute(normal);
assert_eq!(fs::read_to_string(&path).unwrap(), "external");
assert_eq!(editor.lines(), ["local edits"]);
assert!(editor.dirty());
assert!(!editor.should_quit());
assert!(editor.message().contains(":w!"));
editor.execute(force);
assert_eq!(fs::read_to_string(&path).unwrap(), "local edits");
assert!(!editor.dirty());
assert_eq!(editor.should_quit(), quits);
}
}
#[test]
fn external_deletion_is_not_a_new_file_and_invalid_reload_preserves_text() {
let directory = Directory::new();
let path = directory.path("file.txt");
fs::write(&path, "").unwrap();
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
fs::remove_file(&path).unwrap();
assert!(editor.refresh_files());
assert!(!editor.refresh_files());
editor.execute("w");
assert!(!path.exists());
editor.execute("w!");
assert_eq!(fs::read_to_string(&path).unwrap(), "");
fs::write(&path, b"\xff").unwrap();
let revision = editor.text_revision();
assert!(editor.refresh_files());
assert_eq!(editor.text_revision(), revision);
assert_eq!(editor.lines(), [""]);
editor.execute("wq");
assert!(!editor.should_quit());
assert_eq!(fs::read(&path).unwrap(), b"\xff");
fs::write(&path, "recovered\r\n").unwrap();
assert!(editor.refresh_files());
assert_eq!(editor.lines(), ["recovered", ""]);
assert!(!editor.dirty());
let missing = directory.path("new.txt");
let mut new = Editor::open(Some(missing.clone()), vec![]).unwrap();
new.execute("w");
assert_eq!(fs::read_to_string(missing).unwrap(), "");
}
#[test]
fn external_same_text_keeps_history_and_newline_normalization_allows_save() {
let directory = Directory::new();
let path = directory.path("file.txt");
fs::write(&path, "same\r\n").unwrap();
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
editor
.apply(vec![Effect::Replace("old edit".into())])
.unwrap();
editor.handle(Input::Char('u'));
let revision = editor.text_revision();
fs::write(&path, "same\n").unwrap();
assert!(!editor.refresh_files());
assert_eq!(editor.text_revision(), revision);
editor.handle(Input::Char('U'));
assert_eq!(editor.lines(), ["old edit"]);
editor.execute("w");
assert_eq!(fs::read_to_string(path).unwrap(), "old edit");
assert!(!editor.dirty());
}
#[cfg(unix)]
#[test]
fn external_atomic_replacement_with_same_size_and_mtime_reloads() {
let directory = Directory::new();
let path = directory.path("file.txt");
let replacement = directory.path("replacement");
let fixed = std::time::UNIX_EPOCH + std::time::Duration::from_secs(123);
fs::write(&path, "before").unwrap();
fs::File::open(&path).unwrap().set_modified(fixed).unwrap();
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
fs::write(&replacement, "after!").unwrap();
fs::File::open(&replacement)
.unwrap()
.set_modified(fixed)
.unwrap();
fs::rename(&replacement, &path).unwrap();
assert!(editor.refresh_files());
assert_eq!(editor.lines(), ["after!"]);
}
#[test]
fn external_reload_refreshes_working_diff_but_not_historical_snapshots() {
let directory = Directory::new();
let path = directory.path("file.txt");
fs::write(&path, "disk").unwrap();
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
working_diff(&mut editor, &directory.0, &["file.txt"]);
fs::write(&path, "external working").unwrap();
assert!(editor.refresh_files());
assert_eq!(
editor.diff().unwrap().comparison.as_ref().unwrap().files[0].after,
["external working"],
);
editor.execute("diff.close");
historical_diff(
&mut editor,
&directory.0,
vec!["before".into()],
vec!["after".into()],
);
let revision = editor.text_revision();
fs::write(&path, "new disk").unwrap();
assert!(editor.refresh_files());
assert_eq!(editor.lines(), ["before"]);
assert_eq!(editor.text_revision(), revision);
editor.execute("w!");
assert_eq!(fs::read_to_string(path).unwrap(), "new disk");
}
#[test]
fn notification_escape_preserves_carets_then_ordinary_escape_collapses_them() {
let mut editor = editor();
@@ -18788,16 +19186,16 @@ mod tests {
editor
.apply(vec![Effect::Replace("dirty b".into())])
.unwrap();
editor.execute(&format!("w {}", directory.0.join("./a").display()));
assert!(editor.message().contains("belongs to buffer 1"));
assert_eq!(editor.path(), Some(b.as_path()));
assert!(editor.dirty());
assert_eq!(fs::read_to_string(&a).unwrap(), "a");
assert_eq!(fs::read_to_string(&b).unwrap(), "b");
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 2);
editor.execute(&format!("wq {}", a.display()));
assert!(!editor.should_quit());
assert!(editor.dirty());
for command in ["w", "w!", "wq", "wq!"] {
editor.execute(&format!("{command} {}", directory.0.join("./a").display()));
assert!(editor.message().contains("belongs to buffer 1"));
assert_eq!(editor.path(), Some(b.as_path()));
assert!(editor.dirty());
assert!(!editor.should_quit());
assert_eq!(fs::read_to_string(&a).unwrap(), "a");
assert_eq!(fs::read_to_string(&b).unwrap(), "b");
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 2);
}
editor.execute(&format!("w {}", directory.0.join("./b").display()));
assert!(!editor.dirty());
assert_eq!(fs::read_to_string(&b).unwrap(), "dirty b");
@@ -18879,6 +19277,10 @@ mod tests {
assert!(editor.dirty());
assert_eq!(fs::read_to_string(&path).unwrap(), "changed externally");
editor.execute(&format!("w {}", path.display()));
assert!(editor.dirty());
assert_eq!(editor.path(), Some(link.as_path()));
assert_eq!(fs::read_to_string(&path).unwrap(), "changed externally");
editor.execute(&format!("w! {}", path.display()));
assert!(!editor.dirty());
assert_eq!(editor.path(), Some(path.as_path()));
@@ -19107,7 +19509,6 @@ mod tests {
editor.handle(Input::Backspace);
}
type_text(&mut editor, "q!");
assert_eq!(editor.palette().unwrap().entries.len(), 1);
assert!(!palette_entry(&editor, "core.force_quit").enabled);
editor.handle(Input::Enter);
assert!(!editor.should_quit());
@@ -20939,9 +21340,11 @@ mod tests {
editor
.apply(vec![Effect::Replace("not saved".into())])
.unwrap();
editor.execute("wq");
assert!(editor.dirty());
assert!(!editor.should_quit());
for command in ["wq", "wq!"] {
editor.execute(command);
assert!(editor.dirty());
assert!(!editor.should_quit());
}
assert!(
fs::symlink_metadata(&link)
.unwrap()
+11 -3
View File
@@ -40,11 +40,19 @@ Insert: Ctrl+n requests LSP completion at primary caret, then cycles next; Ctrl+
Outside snippets Enter inserts newline, Tab inserts tab; Esc keeps text and returns to Normal
Insert/Command/Search: Ctrl+u deletes backward to the logical line start
Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps)
Commands: :save [PATH], :quit, :force_quit, :save_and_quit [PATH], :plugins, :PLUGIN [ARGS]
Aliases: w=save, q=quit, q!=force_quit, wq=save_and_quit
Commands: :save [PATH], :force_save [PATH], :quit, :force_quit,
:save_and_quit [PATH], :force_save_and_quit [PATH], :plugins, :PLUGIN [ARGS]
Aliases: :w=save, :w!=force_save, :q=quit, :q!=force_quit,
:wq=save_and_quit, :wq!=force_save_and_quit
Save actions: core.save, core.force_save, core.save_and_quit, core.force_save_and_quit
Normal saves recheck LF-normalized disk text; external changes/deletion require force
Force never bypasses snapshot read-only, symlink, or another-buffer path protection
Clean live buffers reload on 250ms metadata checks, including inactive tabs/panes
Reload preserves/clamps views and clears stale undo/redo; snapshots never reload
Dirty buffers keep edits; missing/unreadable/invalid changed files keep text and warn
Buffers: :edit PATH, :buffers, :buffer N, :buffer_next, :buffer_previous, :buffer_delete [N], :buffer_force_delete [N]
Aliases: bnext, bprev, bdelete, bdelete!; arguments and safety rules are unchanged
:quit refuses any dirty buffer; :save_and_quit saves active and quits only if all are clean
:quit refuses any dirty buffer; both save-and-quit commands require all buffers clean
Tabs: :tab.new, :tab.previous, :tab.next, :tab.close, :tab.force_close
Tabs retain pane layouts and independent views; close protects exclusively displayed unsaved edits
Markdown links: :follow_link (primary caret; local files only)
+90 -1
View File
@@ -8,6 +8,7 @@ use std::{
mpsc::{self, Receiver, Sender},
},
thread::{self, JoinHandle},
time::{Duration, Instant},
};
use crate::{
@@ -173,6 +174,7 @@ pub struct EditorSession {
diff_workers: BTreeMap<usize, DiffWorker>,
battery: Option<crate::battery::Battery>,
battery_enabled: bool,
last_file_refresh: Instant,
}
impl EditorSession {
@@ -272,6 +274,7 @@ impl EditorSession {
diff_workers: BTreeMap::new(),
battery: None,
battery_enabled: false,
last_file_refresh: Instant::now(),
};
session.synchronize_documents();
session.poll_battery();
@@ -1044,9 +1047,22 @@ impl EditorSession {
changed
}
fn refresh_files_at(&mut self, now: Instant) -> bool {
if now.saturating_duration_since(self.last_file_refresh) < Duration::from_millis(250) {
return false;
}
self.last_file_refresh = now;
self.editor.refresh_files()
}
/// Drains ready plugin work without waiting for command or server responses.
pub fn poll(&mut self) -> bool {
let mut changed = self.synchronize_documents();
self.poll_at(Instant::now())
}
fn poll_at(&mut self, now: Instant) -> bool {
let mut changed = self.refresh_files_at(now);
changed |= self.synchronize_documents();
changed |= self.poll_battery();
changed |= self.poll_diff();
for update in self.lsp.poll() {
@@ -3396,6 +3412,79 @@ end
assert_eq!(session.editor().label("battery"), "");
}
#[test]
fn idle_poll_refreshes_clean_and_parked_files_on_the_interval_but_keeps_dirty_text() {
let fixture = Fixture::new();
let active = fixture.file("buffer.txt", "old");
let parked = fixture.file("parked.txt", "parked");
let mut session = fixture.open("");
session.execute(&format!("edit {}", parked.display()));
session.execute("buffer 1");
let now = session.last_file_refresh;
let revision = session.editor().text_revision();
fs::write(&active, "external replacement").unwrap();
fs::write(&parked, "external parked replacement").unwrap();
session.poll_at(now + Duration::from_millis(249));
assert_eq!(session.editor().lines(), &["old"]);
session.dispatch(Invocation {
plugin: "core".into(),
command: "lsp.definition".into(),
args: String::new(),
});
assert!(session.requests["core"].latest.is_some());
assert!(session.poll_at(now + Duration::from_millis(250)));
assert!(session.requests["core"].latest.is_none());
assert_eq!(session.editor().lines(), &["external replacement"]);
assert!(session.editor().text_revision() > revision);
let documents: Vec<_> = session.editor().documents().collect();
assert_eq!(documents[1].lines, ["external parked replacement"]);
session.handle(Input::Char('x'));
let dirty = session.editor().lines().to_vec();
let revision = session.editor().text_revision();
fs::write(&active, "second external replacement").unwrap();
session.poll_at(now + Duration::from_millis(500));
assert_eq!(session.editor().lines(), dirty.as_slice());
assert_eq!(session.editor().text_revision(), revision);
assert_eq!(
fs::read_to_string(&active).unwrap(),
"second external replacement"
);
}
#[test]
fn clean_external_reload_notifies_async_plugin_with_change_without_save() {
let fixture = Fixture::new();
let file = fixture.file("buffer.txt", "old");
let mut session = fixture.asynchronous(Some(file.clone()));
barrier(&mut session, "opened");
let now = session.last_file_refresh;
let revision = session.editor().text_revision();
fs::write(&file, "updated external text").unwrap();
assert!(session.poll_at(now + Duration::from_millis(250)));
assert_eq!(session.editor().lines(), &["updated external text"]);
let updated_revision = session.editor().text_revision();
assert!(updated_revision > revision);
assert_eq!(
session.documents[&session.editor().buffer_id()]
.snapshot
.revision,
updated_revision
);
barrier(&mut session, "reload delivered");
let events = fixture.events();
let changes: Vec<_> = events
.iter()
.filter(|event| event["event"] == "change")
.collect();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0]["document"]["text"], "updated external text");
assert_eq!(changes[0]["document"]["revision"], updated_revision);
assert!(!events.iter().any(|event| event["event"] == "save"));
}
#[test]
fn async_documents_include_unnamed_hidden_versions_real_saves_renames_and_closes() {
let fixture = Fixture::new();