fix(editor): keep a chosen file encoding when the file changes on disk (#2792)

Reloads after an external change (`reload_from_disk`) and after undoing a workspace edit (`reload_resource_buffers`) always re-ran encoding detection, so a file the user had reloaded or converted to a legacy encoding came back as mojibake and later saves used the guessed encoding. Documents now record that the encoding was chosen and both reload paths decode with it; files whose encoding was never chosen are still detected again.

Co-authored-by: rohitdanda <10144914+rohitdanda@users.noreply.github.com>
This commit is contained in:
rohitkumardanda
2026-11-12 02:12:13 +08:00
committed by GitHub
co-authored by rohitdanda
parent cb5e7081fa
commit b4bcd8cce7
4 changed files with 76 additions and 3 deletions
+13 -2
View File
@@ -149,6 +149,8 @@ struct DocState {
binary: bool,
dirty: bool,
can_reload_encoding: bool,
/// The user picked `encoding`; reloads keep it instead of detecting again.
encoding_chosen: bool,
/// Disk state we last loaded or wrote; changes matching it are our own.
disk_mtime: f64,
disk_len: u64,
@@ -420,6 +422,7 @@ impl Documents {
binary: loaded.binary,
dirty: false,
can_reload_encoding: true,
encoding_chosen: false,
disk_mtime: loaded.mtime_ms,
disk_len: disk_len(path),
closed: false,
@@ -491,6 +494,7 @@ impl Documents {
binary: false,
dirty: false,
can_reload_encoding,
encoding_chosen: false,
disk_mtime: 0.0,
disk_len: 0,
closed: false,
@@ -866,6 +870,7 @@ impl Documents {
let loaded = Self::load_as(&st.path, encoding, enabled)?;
st.native_to_ascii = enabled;
st.encoding = loaded.encoding.clone();
st.encoding_chosen = true;
st.has_bom = loaded.has_bom;
st.disk_mtime = loaded.mtime_ms;
st.disk_len = disk_len(&st.path);
@@ -884,6 +889,7 @@ impl Documents {
ide_vfs::encode(&st.doc.text_for_save(), encoding, has_bom)?;
}
st.encoding = encoding.to_owned();
st.encoding_chosen = true;
st.has_bom = has_bom;
self.set_dirty(doc, st, true);
Ok(DocumentEncoding { encoding: st.encoding.clone(), has_bom: st.has_bom })
@@ -1020,8 +1026,13 @@ impl Documents {
Self::load_as(&st.path, &st.encoding, st.native_to_ascii)?
} else {
let defaults = self.file_defaults();
let loaded = Self::load_with_defaults(&st.path, &defaults)?;
st.native_to_ascii = defaults.native_to_ascii_for(&st.path);
let native_to_ascii = defaults.native_to_ascii_for(&st.path);
let loaded = if st.encoding_chosen {
Self::load_as(&st.path, &st.encoding, native_to_ascii)?
} else {
Self::load_with_defaults(&st.path, &defaults)?
};
st.native_to_ascii = native_to_ascii;
loaded
};
st.disk_mtime = loaded.mtime_ms;
@@ -748,6 +748,7 @@ impl Documents {
let result = self.with_doc(*id, |state| {
self.replace_locked(*id, state, &before.doc.text())?;
state.encoding = before.encoding.clone();
state.encoding_chosen = before.encoding_chosen;
state.has_bom = before.has_bom;
state.doc.set_line_ending(before.doc.line_ending());
state.disk_mtime = fs::metadata(&state.path).map(|m| ide_vfs::mtime_ms(&m)).unwrap_or(0.0);
@@ -789,7 +790,11 @@ impl Documents {
return Err(stale(path));
}
let defaults = self.file_defaults();
let loaded = ide_vfs::load_text_with_fallback(path, defaults.fallback_for(path))?;
let loaded = if state.encoding_chosen {
Self::load_as(path, &state.encoding, state.native_to_ascii)?
} else {
ide_vfs::load_text_with_fallback(path, defaults.fallback_for(path))?
};
self.replace_locked(id, state, &loaded.text)?;
state.encoding = loaded.encoding;
state.has_bom = loaded.has_bom;
+42
View File
@@ -505,6 +505,48 @@ fn reload_and_convert_encoding() {
assert!(f.docs.set_encoding(info.id, "gbk", true).is_err());
}
#[test]
fn external_changes_keep_a_chosen_encoding() {
// Valid UTF-8 bytes, so detection alone would read them as UTF-8.
let (first, changed) = (b"caf\xC3\xA9".as_slice(), b"caf\xC3\xA9 \xC3\xA8".as_slice());
for (encoding, reload) in [("windows-1252", true), ("gbk", true), ("windows-1252", false)] {
let f = fixture();
let path = f.root.join("legacy.txt");
fs::write(&path, first).unwrap();
let info = f.docs.open(&path).unwrap();
assert_eq!(info.encoding, "utf-8");
if reload {
f.docs.set_encoding(info.id, encoding, true).unwrap();
} else {
// Convert, then save: the file is now in the chosen encoding.
f.docs.replace_all(info.id, "café").unwrap();
f.docs.set_encoding(info.id, encoding, false).unwrap();
f.docs.save(info.id).unwrap();
}
let expected =
|bytes: &[u8]| encoding_rs::Encoding::for_label(encoding.as_bytes()).unwrap().decode(bytes).0.into_owned();
let changed = if reload { changed.to_vec() } else { ide_vfs::encode("café è", encoding, false).unwrap() };
fs::write(&path, &changed).unwrap();
f.docs.on_disk_changed(std::slice::from_ref(&path));
let snapshot = f.docs.snapshot(info.id).unwrap();
assert_eq!(snapshot.text, expected(&changed), "{encoding} reload={reload}");
assert_eq!(snapshot.encoding, encoding, "{encoding} reload={reload}");
assert!(!snapshot.dirty);
}
}
#[test]
fn external_changes_redetect_an_encoding_the_user_never_chose() {
let f = fixture();
let path = f.root.join("plain.txt");
fs::write(&path, "plain").unwrap();
let info = f.docs.open(&path).unwrap();
fs::write(&path, ide_vfs::encode("wide", "utf-16le", true).unwrap()).unwrap();
f.docs.on_disk_changed(std::slice::from_ref(&path));
let snapshot = f.docs.snapshot(info.id).unwrap();
assert_eq!((snapshot.text.as_str(), snapshot.encoding.as_str()), ("wide", "utf-16le"));
}
#[test]
fn utf8_bom_is_added_and_removed_on_save() {
let f = fixture();
@@ -162,6 +162,21 @@ fn undo_id(events: &Mutex<Vec<IdeEvent>>) -> u32 {
.expect("successful transaction has one undo record")
}
#[test]
fn undo_keeps_the_encoding_chosen_for_a_file_opened_after_the_edit() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("legacy.txt");
std::fs::write(&path, b"abc\xC3\xA9").unwrap();
let (docs, events) = recorded_documents();
docs.apply_workspace_edit(&WorkspaceEdit { files: vec![edit(&path, "new")], ..Default::default() }).unwrap();
let info = docs.open(&path).unwrap();
docs.set_encoding(info.id, "windows-1252", true).unwrap();
assert_eq!(docs.snapshot(info.id).unwrap().text, "newé");
docs.undo_workspace_edit(undo_id(&events)).unwrap();
let snapshot = docs.snapshot(info.id).unwrap();
assert_eq!((snapshot.text.as_str(), snapshot.encoding.as_str()), ("abcé", "windows-1252"));
}
#[test]
fn stale_document_version_rejects_the_whole_edit() {
let tmp = tempfile::tempdir().unwrap();