fix(editor): give a saved file back the line endings it arrived with

Enter belongs to the input widget and inserts a bare \n whatever the file
around it does. Editing one line of a file checked out with CRLF therefore
mixed the two endings, and git reports that as a rewrite of lines the user
never visited.

The buffer still keeps the exact bytes it was given — that part was already
right, and there are now tests holding it there, along with hard tabs and a
missing final newline. What changed is the save: a file that arrived
uniformly CRLF goes back out uniformly CRLF. Deliberately unanimous, so a
file that already mixes its endings is still written exactly as found rather
than being handed a winner it never asked for.

Saving had no test at all before this; the new one writes a real file and
reads the bytes back.
This commit is contained in:
l0ng-ai
2026-08-23 09:06:20 +08:00
parent d6de373619
commit 5ce0fbdd5a
+198
View File
@@ -40,6 +40,14 @@ pub(crate) struct OpenFile {
save_then_close: bool,
reload_seq: u64,
pub(crate) conflict: bool,
/// Whether the file arrived with every line ending `\r\n`.
///
/// The buffer keeps the bytes it was given, `\r` and all, but Enter is the
/// widget's own and inserts a bare `\n` whatever the rest of the file
/// does. Left alone, editing one line of a CRLF file mixes the two endings
/// and git reports the whole line rewritten. Recorded here so the save can
/// put back the ending the file already had.
crlf: bool,
pub(crate) preview: bool,
pub(crate) wrap: bool,
/// The rendered-Markdown pane's own scroll. Per file, so switching away
@@ -232,6 +240,39 @@ fn place_cursor(
window.refresh();
}
/// Whether every line ending in `text` is `\r\n`, and there is at least one.
///
/// Deliberately unanimous rather than a majority. A file that already mixes
/// its endings is one this editor should hand back exactly as it found it —
/// guessing a winner there would rewrite lines the user never touched.
fn uniformly_crlf(text: &str) -> bool {
let bytes = text.as_bytes();
let mut saw_one = false;
for (i, b) in bytes.iter().enumerate() {
if *b == b'\n' {
if i == 0 || bytes[i - 1] != b'\r' {
return false;
}
saw_one = true;
}
}
saw_one
}
/// Gives every bare `\n` in `text` the `\r` its neighbours have.
fn to_crlf(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut prev = '\0';
for c in text.chars() {
if c == '\n' && prev != '\r' {
out.push('\r');
}
out.push(c);
prev = c;
}
out
}
fn looks_binary(bytes: &[u8]) -> bool {
bytes.iter().take(8192).any(|b| *b == 0)
}
@@ -302,6 +343,7 @@ impl Tty7App {
save_then_close: false,
reload_seq: 0,
conflict: false,
crlf: false,
preview: false,
wrap: false,
preview_scroll: gpui::ScrollHandle::new(),
@@ -686,6 +728,7 @@ impl Tty7App {
return;
}
let language = language_for_path(&path);
let crlf = uniformly_crlf(&text);
let input = cx.new(|cx| {
InputState::new(window, cx)
.code_editor(language)
@@ -740,6 +783,7 @@ impl Tty7App {
save_then_close: false,
reload_seq: 0,
conflict: false,
crlf,
preview: false,
wrap: false,
preview_scroll: gpui::ScrollHandle::new(),
@@ -854,7 +898,9 @@ impl Tty7App {
}
let seq = f.edit_seq;
f.saving = Some(seq);
let crlf = f.crlf;
let text = f.input.read(cx).text().to_string();
let text = if crlf { to_crlf(&text) } else { text };
let target = f.path.clone();
let host_id = host.id();
let saved_in = target.parent().map(std::path::Path::to_path_buf);
@@ -1134,6 +1180,7 @@ impl Tty7App {
f.disk_mtime = mtime;
f.dirty = false;
f.conflict = false;
f.crlf = uniformly_crlf(&text);
f.edit_seq = f.edit_seq.wrapping_add(1);
let input = f.input.clone();
input.update(cx, |input, cx| input.set_value(text, window, cx));
@@ -1607,6 +1654,7 @@ mod tests {
#[cfg(all(test, unix))]
mod unsaved_close_gpui_tests {
use super::{to_crlf, uniformly_crlf};
use gpui::TestAppContext;
use crate::ui::app::test_window::harness_with_tabs;
@@ -1698,4 +1746,154 @@ mod unsaved_close_gpui_tests {
);
});
}
/// A buffer hands back exactly the bytes it was given.
///
/// This is what `editor_save_file` writes, so anything the buffer quietly
/// tidies on the way in is written over the user's file the first time
/// they hit save — without them having edited that part of it. The cases
/// are the ones an editor is most often caught rewriting: both line
/// endings, hard tabs in a file where they are load-bearing, and a final
/// line with no newline after it.
#[gpui::test]
fn a_buffer_hands_back_the_bytes_it_was_given(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
app.update_in(&mut vcx, |_app, window, cx| {
for (what, text) in [
("windows line endings", "a\r\nb\r\n"),
("a make recipe's hard tabs", "all:\n\tcc -o x x.c\n"),
("no newline at end of file", "last line"),
("a blank line at the end", "a\n\n"),
("a bare carriage return", "a\rb"),
("text that is not ascii", "\u{4f60}\u{597d} \u{1f600}\n"),
("whitespace nobody trimmed", "a \nb\t\n"),
] {
let input = new_buffer(text, window, cx);
assert_eq!(
input.read(cx).text().to_string(),
text,
"the buffer rewrote {what}"
);
}
});
}
/// A file that came in with Windows endings goes back out with them.
///
/// Enter belongs to the input widget and inserts a bare `\n` whatever the
/// file around it does, so every line added to a CRLF file used to end
/// differently from every line already in it — which git reports as a
/// rewrite of lines the user never visited.
#[gpui::test]
fn a_line_added_to_a_crlf_file_keeps_the_file_s_own_ending(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
app.update_in(&mut vcx, |_app, window, cx| {
let input = new_buffer("one\r\ntwo\r\n", window, cx);
// What Enter leaves behind, spelled out: the widget's own newline.
input.update(cx, |state, cx| state.insert("three\n", window, cx));
let saved = to_crlf(&input.read(cx).text().to_string());
assert!(
!saved.contains("\n") || saved.split("\n").count() - 1 == saved.matches("\r\n").count(),
"a bare newline survived into a CRLF file: {saved:?}"
);
assert!(saved.contains("three\r\n"), "the added line is there: {saved:?}");
});
}
/// The same buffer with Unix endings is not given carriage returns.
#[gpui::test]
fn a_unix_file_is_left_alone(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
app.update_in(&mut vcx, |_app, window, cx| {
let input = new_buffer("one\ntwo\n", window, cx);
input.update(cx, |state, cx| state.insert("three\n", window, cx));
let text = input.read(cx).text().to_string();
assert!(!uniformly_crlf("one\ntwo\n"), "a unix file is not a crlf one");
assert!(!text.contains('\r'), "a carriage return appeared: {text:?}");
});
}
/// A file that already mixes its endings is handed back untouched.
#[test]
fn a_mixed_file_is_not_given_a_winner() {
assert!(!uniformly_crlf("a\r\nb\n"), "mixed endings are not CRLF");
assert!(!uniformly_crlf("a\nb\r\n"), "in either order");
assert!(!uniformly_crlf("no newline at all"), "and neither is a single line");
assert!(uniformly_crlf("a\r\n"), "one CRLF line is a CRLF file");
}
/// Saving actually writes, and writes the file's own line ending.
///
/// The buffer-level tests above cannot see the save path: they check what
/// `to_crlf` does, not that anybody calls it. This one opens a real file,
/// adds a line the way Enter would, saves through the ordinary path, and
/// reads the bytes back off the disk.
#[gpui::test]
fn a_saved_crlf_file_is_still_a_crlf_file_on_disk(cx: &mut TestAppContext) {
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
let dir = std::env::temp_dir().join(format!("tty7-crlf-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("a scratch directory");
let path = dir.join("windows.txt");
std::fs::write(&path, b"one\r\ntwo\r\n").expect("seed the file");
app.update_in(&mut vcx, |app, window, cx| {
let host = tty7_core::host::local::LocalHost::new();
app.editor_install_file(
host,
path.clone(),
"one\r\ntwo\r\n".to_string(),
None,
window,
cx,
);
let input = app
.tab_code()
.and_then(|c| c.files.first())
.map(|f| f.input.clone())
.expect("the file is open");
// The bare newline is the point: it is what Enter inserts.
input.update(cx, |state, cx| state.insert("three\n", window, cx));
app.editor_save_file(input.entity_id(), false, window, cx);
});
// The write goes out on a blocking pool that `run_until_parked` does
// not wait for, so watch the file rather than assume a single turn.
let mut wrote = Vec::new();
for _ in 0..200 {
vcx.run_until_parked();
wrote = std::fs::read(&path).unwrap_or_default();
if wrote != b"one\r\ntwo\r\n" {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
let _ = std::fs::remove_file(&path);
assert_eq!(
String::from_utf8_lossy(&wrote),
"three\r\none\r\ntwo\r\n",
"the saved file kept the endings it came with"
);
}
/// Building the buffer the way `editor_install_file` does.
fn new_buffer(
text: &str,
window: &mut gpui::Window,
cx: &mut gpui::App,
) -> gpui::Entity<gpui_component::input::InputState> {
use gpui::AppContext as _;
use gpui_component::input::{InputState, TabSize};
cx.new(|cx| {
InputState::new(window, cx)
.code_editor("rust")
.multi_line(true)
.tab_size(TabSize {
tab_size: 4,
hard_tabs: false,
})
.soft_wrap(false)
.default_value(text)
})
}
}