From b4bcd8cce7d53806884ef8bf2d20bac533c3602c Mon Sep 17 00:00:00 2001 From: rohitkumardanda Date: Wed, 11 Nov 2026 18:12:13 +0000 Subject: [PATCH] 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> --- crates/ide-workspace/src/documents.rs | 15 ++++++- .../src/documents/workspace_edit.rs | 7 +++- crates/ide-workspace/tests/documents.rs | 42 +++++++++++++++++++ crates/ide-workspace/tests/workspace_edits.rs | 15 +++++++ 4 files changed, 76 insertions(+), 3 deletions(-) diff --git a/crates/ide-workspace/src/documents.rs b/crates/ide-workspace/src/documents.rs index 0f89ac5be..7b4832744 100644 --- a/crates/ide-workspace/src/documents.rs +++ b/crates/ide-workspace/src/documents.rs @@ -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; diff --git a/crates/ide-workspace/src/documents/workspace_edit.rs b/crates/ide-workspace/src/documents/workspace_edit.rs index ca1a1cbc2..51f8e2bd5 100644 --- a/crates/ide-workspace/src/documents/workspace_edit.rs +++ b/crates/ide-workspace/src/documents/workspace_edit.rs @@ -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; diff --git a/crates/ide-workspace/tests/documents.rs b/crates/ide-workspace/tests/documents.rs index 167f975c7..4b73ba4ff 100644 --- a/crates/ide-workspace/tests/documents.rs +++ b/crates/ide-workspace/tests/documents.rs @@ -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(); diff --git a/crates/ide-workspace/tests/workspace_edits.rs b/crates/ide-workspace/tests/workspace_edits.rs index e58d7f52e..cf50728b8 100644 --- a/crates/ide-workspace/tests/workspace_edits.rs +++ b/crates/ide-workspace/tests/workspace_edits.rs @@ -162,6 +162,21 @@ fn undo_id(events: &Mutex>) -> 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();