From 6bf5d6c7e6a8ec9242055c973f8da9ee57930357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 14:39:58 -0400 Subject: [PATCH] ElyMark: keys typed before an outside write survive; same text adopts the file; autosave resumes (M-01) --- apps/ely-mark/src/autosave.rs | 8 +++ apps/ely-mark/src/compare.rs | 6 +- apps/ely-mark/src/outside.rs | 90 +++++++++++++++++++++++++++-- tasks/BUGS.md | 2 +- tasks/reviews/M-01-r1.md | 5 ++ tasks/reviews/M-01-r2.md | 5 ++ tasks/reviews/M-01-r3.md | 3 + tasks/reviews/M-01-r4.md | 3 + tasks/reviews/M-01-r5.md | 1 + tests/scripts/mark-outside.sh | 103 +++++++++++++++++++++++++++++++++- 10 files changed, 215 insertions(+), 11 deletions(-) create mode 100644 tasks/reviews/M-01-r1.md create mode 100644 tasks/reviews/M-01-r2.md create mode 100644 tasks/reviews/M-01-r3.md create mode 100644 tasks/reviews/M-01-r4.md create mode 100644 tasks/reviews/M-01-r5.md diff --git a/apps/ely-mark/src/autosave.rs b/apps/ely-mark/src/autosave.rs index 5ae8313..481e524 100644 --- a/apps/ely-mark/src/autosave.rs +++ b/apps/ely-mark/src/autosave.rs @@ -29,6 +29,14 @@ impl Desk { return; } tab.extra.heard = changed; + self.save_after_pause(id, cx); + } + + /// Counts unsaved text as an edit; saves after the pause. + pub fn save_after_pause(&mut self, id: u64, cx: &mut Context) { + let Some(tab) = self.tab_mut(id) else { + return; + }; tab.extra.policy.edit(Instant::now()); cx.spawn(async move |desk, cx| { let wait = DEBOUNCE + Duration::from_millis(50); diff --git a/apps/ely-mark/src/compare.rs b/apps/ely-mark/src/compare.rs index ca31c96..35a7d14 100644 --- a/apps/ely-mark/src/compare.rs +++ b/apps/ely-mark/src/compare.rs @@ -2,7 +2,7 @@ use std::rc::Rc; -use ely_files::{Fingerprint, Policy, PolicyEngine}; +use ely_files::Fingerprint; use ely_gpui_component::{ buttons::{Button, ButtonVariant}, git::{self as merging, Region, Take, ThreeWayMerge}, @@ -102,7 +102,7 @@ impl Desk { Choice::Mine => { if let Some(tab) = self.tab_mut(id) { tab.session.rebase(fingerprint); - tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true); + tab.extra.restart_autosave(); } self.set_outside(id, Outside::Clear, cx); self.save(id, None, window, cx); @@ -120,7 +120,7 @@ impl Desk { return self.set_outside(id, Outside::Unread(why), cx); } tab.extra.base = disk.text.clone(); - tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true); + tab.extra.restart_autosave(); tab.doc.lock().expect("the text lock").source = disk; let view = tab.view.clone(); view.update(cx, |view, cx| view.adopt(merged, cx)); diff --git a/apps/ely-mark/src/outside.rs b/apps/ely-mark/src/outside.rs index daa68d6..631dccf 100644 --- a/apps/ely-mark/src/outside.rs +++ b/apps/ely-mark/src/outside.rs @@ -55,6 +55,14 @@ pub struct Extra { pub restored: bool, } +impl Extra { + /// Autosave from scratch; a write in flight no longer counts. + pub fn restart_autosave(&mut self) { + self.policy = PolicyEngine::new(Policy::MarkAutoSave, true); + self.writing = None; + } +} + impl Default for Extra { fn default() -> Self { Extra { @@ -217,11 +225,26 @@ impl Desk { Ok(read) => read, Err(error) => return self.set_outside(id, Outside::Unread(error.to_string()), cx), }; + // Keys the page has not sent yet count first. + if !self.settled(id, cx) { + return self.when_settled(id, move |desk, _, cx| desk.disk_read(id, path, Ok((fingerprint, disk)), cx)); + } let current = tab.doc.lock().expect("the text lock").current.clone(); + let kept = disk.text == tab.extra.base; let dirty = tab.dirty(cx); log::info!("mark: {} changed outside; unsaved {dirty}", path.display()); match (disk.text == current, dirty) { - (true, _) | (false, false) => self.take_disk(id, disk, fingerprint, cx), + // The same text: only the file underneath is new. + (true, _) => { + if let Some(view) = self.adopt_disk(id, &disk, fingerprint, cx) { + // What shows is now saved: the tab is clean. + let saved = crate::engine::mark(&disk.text); + view.update(cx, |view, cx| view.saved(&saved, cx)); + } + } + // The text as last saved: the edits stay unsaved. + (false, true) if kept => self.keep_edits(id, disk, fingerprint, cx), + (false, false) => self.take_disk(id, disk, fingerprint, cx), (false, true) => { let tab = self.tab_mut(id).expect("open"); tab.session.external_modified(); @@ -247,14 +270,47 @@ impl Desk { fingerprint: Fingerprint, cx: &mut Context, ) { + if let Some(view) = self.adopt_disk(id, &disk, fingerprint, cx) { + view.update(cx, |view, cx| view.refresh(disk, cx)); + } + } + + /// A new file with the saved text; edits stay. + fn keep_edits(&mut self, id: u64, disk: Source, fingerprint: Fingerprint, cx: &mut Context) { let Some(tab) = self.tab_mut(id) else { return; }; + tab.session.rebase(fingerprint); + tab.doc.lock().expect("the text lock").source = disk; + // A conflict stopped autosave, maybe mid-write; it resumes. + tab.extra.restart_autosave(); + let (registry, path) = (tab.registry, tab.session.path().map(Path::to_path_buf)); + if let (Some(registry), Some(path)) = (registry, path) + && let Err(error) = self.core.sessions.track(registry, &path) + { + log::warn!("mark: the replaced file did not register: {error}"); + } + self.set_outside(id, Outside::Clear, cx); + self.save_after_pause(id, cx); + } + + /// The tab takes the file as saved; returns its view. + fn adopt_disk( + &mut self, + id: u64, + disk: &Source, + fingerprint: Fingerprint, + cx: &mut Context, + ) -> Option> { + let tab = self.tab_mut(id)?; if let Err(error) = tab.session.reload_at(fingerprint) { - return self.set_outside(id, Outside::Unread(error.to_string()), cx); + self.set_outside(id, Outside::Unread(error.to_string()), cx); + return None; } tab.extra.base = disk.text.clone(); - tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true); + tab.extra.restart_autosave(); + // Its bytes (BOM, line ends) are what saves keep now. + tab.doc.lock().expect("the text lock").source = disk.clone(); let (view, registry) = (tab.view.clone(), tab.registry); let path = tab.session.path().map(Path::to_path_buf); // An atomic replace is a new file underneath. @@ -263,8 +319,8 @@ impl Desk { { log::warn!("mark: the replaced file did not register: {error}"); } - view.update(cx, |view, cx| view.refresh(disk, cx)); self.set_outside(id, Outside::Clear, cx); + Some(view) } pub fn set_outside(&mut self, id: u64, outside: Outside, cx: &mut Context) { @@ -345,3 +401,29 @@ fn watch(dirs: &[PathBuf], cx: &mut Context) -> notify::Result"$doc" +more "changed outside" "$changed" +# A conflict the disk takes back resumes autosave. +cp "$doc" "$work/saved" +conflicts=$(count "unsaved true") +now X +printf 'external\n' >"$doc" +more "unsaved true" "$conflicts" +cp "$work/saved" "$doc" +waits "local edits stay" +before=$(count autosaves) +now Y +autosaved "$before" +is "$doc" $'one\n\ntwo\n\nthreeZXKXY' "autosave after the disk came back" +before=$(count autosaves) +key 'key code 51' +key 'key code 51' +autosaved "$before" +is "$doc" $'one\n\ntwo\n\nthreeZXK' "keys taken back again" +# Unsaved keys meet the saved text in new bytes. +cp "$doc" "$work/saved" +python3 -c 'import sys; d = open(sys.argv[1], "rb").read(); open(sys.argv[2], "wb").write(b"\xef\xbb\xbf" + d.replace(b"\n", b"\r\n"))' "$doc" "$work/crlf" +stays=$(count "local edits stay") +before=$(count autosaves) +now V +cp "$work/crlf" "$doc" +more "local edits stay" "$stays" +autosaved "$before" +kept V || fail "edits kept lost the new bytes: $(od -c "$doc" | head -3)" +before=$(count autosaves) +key 'key code 51' +autosaved "$before" +cmp -s "$doc" "$work/crlf" || fail "not the new bytes: $(od -c "$doc" | head -3)" +changed=$(count "changed outside") +cp "$work/saved" "$doc" +more "changed outside" "$changed" +# Unsaved text written outside as is: the tab is clean. +python3 -c 'import sys; d = open(sys.argv[1], "rb").read(); open(sys.argv[2], "wb").write(d[:-1] + b"S\n" if d.endswith(b"\n") else d + b"S")' "$doc" "$work/typed" +cp "$doc" "$work/saved" +changed=$(count "changed outside") +now S +cp "$work/typed" "$doc" +more "changed outside" "$changed" +sleep 2 +clean=$(count "unsaved false") +printf 'gamma\n' >>"$doc" +more "unsaved false" "$clean" +sleep 1 +grep -q gamma "$doc" || fail "the outside change was not taken" +changed=$(count "changed outside") +cp "$work/saved" "$doc" +more "changed outside" "$changed" +# A save that meets the same text keeps saving after. +python3 -c 'import sys; d = open(sys.argv[1], "rb").read(); open(sys.argv[2], "wb").write(d[:-1] + b"T\n" if d.endswith(b"\n") else d + b"T")' "$doc" "$work/same" +before=$(count autosaves) +now T +for _ in $(seq 200); do [ "$(count autosaves)" -gt "$before" ] && break; sleep 0.01; done +cp "$work/same" "$doc" +sleep 2 +before=$(count autosaves) +now U +autosaved "$before" +grep -q TU "$doc" || fail "the next edit after a same-text save was not saved: $(tr '\n' '/' <"$doc")" +before=$(count autosaves) +key 'key code 51' +key 'key code 51' +autosaved "$before" # Edits apart merge; a disk change mid-way compares again. key 'key code 126 using command down' conflicts=$(count "unsaved true") @@ -208,8 +305,8 @@ app= launch waits "ended Crashed" waits "did not open" -for _ in $(seq 40); do [ "$(count "toast .*unsaved text from")" -ge 2 ] && break; sleep 0.25; done -[ "$(count "toast .*unsaved text from")" = 2 ] || fail "recovered copies: $(grep "unsaved text from" "$work/err")" +for _ in $(seq 40); do [ "$(count ": unsaved text from .* is back")" -ge 2 ] && break; sleep 0.25; done +[ "$(count ": unsaved text from .* is back")" = 2 ] || fail "recovered copies: $(grep "unsaved text from" "$work/err")" sleep 3 is "$doc" "disk" "recovered text went into the file" [ ! -e "$gone" ] || fail "a deleted file came back" @@ -246,7 +343,7 @@ app= launch waits "ended Crashed" sleep 4 -[ "$(count "toast .*unsaved text from")" = 2 ] || fail "after a discard: $(grep "toast .*unsaved text from" "$work/err")" +[ "$(count ": unsaved text from .* is back")" = 2 ] || fail "after a discard: $(grep ": unsaved text from .* is back" "$work/err")" key 'keystroke "q" using command down' for _ in $(seq 4); do for _ in $(seq 20); do seen "Don't Save" && break; sleep 0.25; done