ElyMark: keys typed before an outside write survive; same text adopts the file; autosave resumes (M-01)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-09 14:39:58 -04:00
1 parent c9bfccc91c
commit 6bf5d6c7e6
10 files changed
+215 -11

No files matched your search

+8
View File
@@ -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<Self>) {
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);
+3 -3
View File
@@ -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));
+86 -4
View File
@@ -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<Self>,
) {
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<Self>) {
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<Self>,
) -> Option<gpui::Entity<crate::view::MarkView>> {
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<Self>) {
@@ -345,3 +401,29 @@ fn watch(dirs: &[PathBuf], cx: &mut Context<Desk>) -> notify::Result<Recommended
log::info!("mark: watching {} folders", dirs.len());
Ok(watcher)
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use ely_files::Action;
use super::Extra;
#[test]
fn a_restarted_autosave_saves_the_next_edit() {
let mut extra = Extra::default();
extra.policy.set_has_path();
extra.policy.edit(Instant::now());
// A write was in flight when the disk changed.
extra.writing = Some(1);
extra.restart_autosave();
// As autosave settles a heard write, then edits.
if let Some(revision) = extra.writing.take() {
extra.policy.save_finished(revision, true);
}
let now = Instant::now();
extra.policy.edit(now);
assert!(matches!(extra.policy.tick(now + Duration::from_secs(5)), Action::SaveDue(_)));
}
}
+1 -1
View File
@@ -55,7 +55,7 @@
- [ ] D-06 导航栏大纲点击按当前列表取下标,后台重读后可能越界
- [ ] D-07 导航栏隐藏时仍随每次光标移动重读大纲与书签
- [ ] D-08 Linux、Windows 上“打印…”可用,导出 PDF 后才报“此平台未构建”
- [ ] M-01 键入期间外部以相同内容重写文件,丢掉上次停顿之后键入的全部文字
- [x] M-01 键入期间外部以相同内容重写文件,丢掉上次停顿之后键入的全部文字 — 先等页面送出未送的键;同文只接过文件与字节、标签为已保存;磁盘回到上次保存的文字时保留编辑并恢复自动保存;R1–R4 字节、自动保存、在途写入、已保存基线;R5 PASS
- [ ] M-02 `refresh` 归零版本计数却不换代,在途的一键让标签一直“未落定”
- [ ] M-03 干净标签接过磁盘文字后关闭仍问“是否保存”
- [ ] M-04 第二次打印或导出 PDF 悄悄取消第一次,没有文件也没有错误
+5
View File
@@ -0,0 +1,5 @@
FAIL
apps/ely-mark/src/outside.rs:231(adopt_disk:294):相同文本分支采用新指纹,却保留旧的 doc.source,后续保存使用旧 BOM、换行格式及原始字节,违反 PRD §14.3。真窗口复现两次:打开字节为 b"alpha\n\nbeta\n" 的文件,外部替换为 b"\xef\xbb\xbfalpha\r\n\r\nbeta\r\n",等待 changed outside; unsaved false,再按 Cmd+S;磁盘恢复为原来的 LF 文件,BOM 消失。证据:/tmp/ely-m01-probe-rerun.log 的 bytes expected/actual。修复方向:相同文本采用磁盘版本时同步 doc.source,保持编辑器实例及待处理输入。
apps/ely-mark/src/outside.rs:271(keep_edits:278):磁盘恢复上次保存文本后,只 rebase 会话并清除提示,先前冲突停止的 PolicyEngine 持续处于 stopped 状态;后续键入也无法恢复默认自动保存,违反 PRD §6.2,并撤下了处理该状态的入口。真窗口复现两次:打开 alpha/beta 文档,键入 X,外部写入 external 触发冲突,再把磁盘恢复为原始文本;等待 local edits stay 后键入 Y、停手三秒,磁盘仍是原文,autosaves=0;手动 Cmd+S 才写出 b"XYalpha\n\nbeta\n"。证据:/tmp/ely-m01-probe-rerun.log 及 /tmp/ely-m01-restart.log。修复方向:解除冲突时同步恢复自动保存策略,并为保留的未保存编辑安排停手保存。
+5
View File
@@ -0,0 +1,5 @@
FAIL
apps/ely-mark/src/outside.rs:234(keep_edits:267):保留本地编辑的分支只接过磁盘指纹,丢弃读到的 Source,后续保存仍使用旧 doc.source 的 BOM、换行格式和原始字节,违反 PRD §14.3。真窗口独立复现三次:打开 b"alpha\n\nbeta\n",在开头键入 X,停手保存前外部写入 b"\xef\xbb\xbfalpha\r\n\r\nbeta\r\n";日志出现 local edits stay,自动保存却写出 b"Xalpha\n\nbeta\n"。删掉 X 后再次保存也写回旧 LF 字节。证据:/tmp/ely-m01-r2-probe-results.log、/tmp/ely-m01-r2-stable-1-results.log、/tmp/ely-m01-r2-stable-2-results.log。修复方向:keep_edits 接收并采用本次磁盘 Source,保留编辑器、当前文字与编辑历史;回归覆盖存在未保存输入时的 BOM/CRLF 基线重写。
apps/ely-mark/src/outside.rs:273(autosave.rs:56):重建保存策略时保留了失败自动保存的 extra.writing,save_after_pause 安排的任务随后被 writing.is_some() 阻挡,保留的编辑持续未保存,违反 PRD §6.2。真窗口碰撞探针连续复现两次:打开 alpha/beta 文档并键入 X,在 autosaves tab 日志出现后立即以原文重写文件;保存指纹检查报告 meets an external change,随后 rebase 报 local edits stay。停手四秒,autosaves 仍只有首次失败的一次,磁盘仍为 b"alpha\n\nbeta\n";手动 Cmd+S 才写出 b"Xalpha\n\nbeta\n"。证据:/tmp/ely-m01-r2-collision-2-results.log、/tmp/ely-m01-r2-collision-3-results.log 及对应 save-collision.log。修复方向:恢复策略时结清旧自动保存的 writing 状态与修订归属,再安排保留编辑的保存;回归覆盖自动保存撞上同内容外部重写后,持续停手即可完成保存。
+3
View File
@@ -0,0 +1,3 @@
FAIL
apps/ely-mark/src/outside.rs:300(同文本分支:231;autosave.rs:20):adopt_disk 重建保存策略时保留失败自动保存的 extra.writing,下一轮编辑把旧修订结算到新策略,导致该轮自动保存被跳过,违反 PRD §6.2。真窗口独立复现两次:打开 b"alpha\n\nbeta\n",在开头键入 X,首次 autosaves tab 日志出现时外部写入 b"Xalpha\n\nbeta\n",保存指纹检查报告 meets an external change,同文本分支接过磁盘;随后键入 Y 并停手三秒,磁盘仍为 b"Xalpha\n\nbeta\n",autosaves 仍只有一次;再键入 Z 才自动保存 b"XYZalpha\n\nbeta\n"。原因是 autosave 将旧 writing=1 作为成功保存结算,新策略的 persisted 变成 1,随后 Y 的 edit 也得到 revision=1,tick 因两者相等跳过保存。证据:/tmp/ely-m01-r3-native-results.log、/tmp/ely-m01-r3-collision-current-1.log、/tmp/ely-m01-r3-collision-current-2.log。修复方向:adopt_disk 重建策略时同步清除旧 extra.writing,并覆盖同文本碰撞后首轮编辑停手即可保存的回归场景。
+3
View File
@@ -0,0 +1,3 @@
FAIL
apps/ely-mark/src/outside.rs:239(adopt_disk:302-309):同文本接管更新会话、Source 和保存策略时,保留了旧的 MarkView.saved 与 tab.modified;当前内容已全部写在磁盘上,标签仍参与未保存冲突判定,违反 PRD §14.4、FILE-02。真窗口独立复现三次:打开 b"alpha\n\nbeta\n",在开头键入 X,自动保存前外部写入 b"Xalpha\n\nbeta\n";等待 session: reloaded 并停手三秒,再由外部追加 b"\ngamma\n"。应用记录 changed outside; unsaved true、停止自动保存,AX 树显示 “t.md changed on disk; your unsaved text is kept” 和 Compare… 按钮;按 §14.4 此时应作为干净文件接收外部更新。证据:/tmp/ely-m01-r4-adopt-clean-1.log、/tmp/ely-m01-r4-adopt-clean-2.log、/tmp/ely-m01-r4-adopt-ax-native.log、/tmp/ely-m01-r4-adopt-ax.log。修复方向:同文本接管时同步视图的已保存基线与标签修改状态,保留页面及编辑历史;回归覆盖“外部写入当前编辑文本,随后再次外部修改”的状态转换。
+1
View File
@@ -0,0 +1 @@
PASS
+100 -3
View File
@@ -101,6 +101,103 @@ before=$(count autosaves)
key 'keystroke "K"'
autosaved "$before"
is "$doc" $'one\n\ntwo\n\nthreeZXK' "autosave after keep"
# Unsent keys survive a touch that keeps the text.
before=$(count autosaves)
changed=$(count "changed outside")
now QQ
touch "$doc"
more "changed outside" "$changed"
autosaved "$before"
is "$doc" $'one\n\ntwo\n\nthreeZXKQQ' "keys typed before a touch were lost"
before=$(count autosaves)
key 'key code 51'
key 'key code 51'
autosaved "$before"
is "$doc" $'one\n\ntwo\n\nthreeZXK' "keys taken back"
# Same text, new bytes: saves keep the new bytes.
lf=$(od -An -tx1 "$doc" | tr -d ' \n')
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"
# BOM, only CRLF line ends, and `$1` in it.
kept() { python3 -c 'import sys; d = open(sys.argv[1], "rb").read(); sys.exit(not (d.startswith(b"\xef\xbb\xbf") and d.count(b"\n") == d.count(b"\r\n") and sys.argv[2].encode() in d))' "$doc" "$1"; }
changed=$(count "changed outside")
cp "$work/crlf" "$doc"
more "changed outside" "$changed"
before=$(count autosaves)
now W
autosaved "$before"
kept W || fail "a save lost the new bytes: $(od -c "$doc" | head -3)"
before=$(count autosaves)
key 'key code 51'
autosaved "$before"
cmp -s "$doc" "$work/crlf" || fail "the save is not the new bytes: $(od -c "$doc" | head -3)"
changed=$(count "changed outside")
printf '%s' "$lf" | xxd -r -p >"$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