feat: reload clean buffers and guard external-change writes
This commit is contained in:
+434
-31
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user