1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
//! Idle-debounce autosave.
//!
//! Every dirtying edit resets a debounce window; once the user stops
//! typing for `config.editor.autosave_idle_ms`, the dirty buffer is
//! written to disk (no dialog, no confirmation) and an `Autosaved`
//! transient is flashed on the hint line. Buffers without an
//! associated path skip without any UI — the user named no file, so
//! there's nowhere to save. Save failures escalate to a sticky
//! `NoticeModal` via [`App::notify`] so the user can't miss them.
//!
//! The run loop drives this from [`App::tick_timers`] (once per
//! iteration) and consults [`App::autosave_deadline`] in
//! [`App::next_deadline`] so the channel `recv_timeout` wakes exactly
//! when the debounce window expires — no polling.
use std::time::{Duration, Instant};
use crate::ui::ModalKind;
use super::flash::MessageKind;
use super::App;
impl App {
/// Per-iteration autosave step. Detects edits via
/// [`Buffer::version`](crate::document::Buffer::version), so a
/// typing burst restarts the debounce window on every keystroke
/// rather than firing mid-burst. No return value: both the
/// success path ([`App::flash`]) and the failure path
/// ([`App::notify`]) already set `needs_draw`, so the caller in
/// `tick_timers` doesn't need to.
pub(super) fn tick_autosave(&mut self) {
// Diff mode is review-in-progress; saving mid-review would
// clobber the on-disk file the user is reconciling against.
// Drop any armed timer so it doesn't fire the instant the
// user exits diff mode, and skip arming new ones.
if self.editor.mode == crate::editor::Mode::Diff {
self.autosave_pending_since = None;
return;
}
// A live `:s` preview has transiently rewritten the buffer (raw
// buffer edits bump `version` without touching `dirty`); on an
// already-dirty buffer the debounce would arm and autosave the
// preview text to disk. Skip entirely — once the preview reverts
// or commits, the next tick sees a version change and re-arms.
if self.editor.substitute_preview.is_some() {
self.autosave_pending_since = None;
return;
}
let enabled = self.config.editor.autosave_enabled;
let version = self.editor.buffer.version();
// Detect edits: any version change is an edit. Reset the
// debounce window even when autosave is disabled so re-enabling
// it later doesn't fire instantly off a stale timestamp.
if version != self.autosave_last_seen_version {
self.autosave_last_seen_version = version;
if enabled && self.editor.dirty && self.editor.buffer.path().is_some() {
self.autosave_pending_since = Some(Instant::now());
}
}
// Clean buffer (e.g. manual Save) — clear any pending timer.
if !self.editor.dirty {
self.autosave_pending_since = None;
return;
}
// No pending timer (autosave disabled, unnamed buffer, or
// freshly-cleared) — nothing to do this tick.
let Some(since) = self.autosave_pending_since else {
return;
};
// Autosave was toggled off after the timer was armed: drop the
// pending save without writing. Re-enabling later will re-arm
// on the next edit. Symmetric with `autosave_deadline`, which
// already returns `None` when disabled.
if !enabled {
self.autosave_pending_since = None;
return;
}
let idle = Duration::from_millis(self.config.editor.autosave_idle_ms);
if since.elapsed() < idle {
return;
}
// Window elapsed: persist through the unified save helper so
// any future side effect (e.g. the watcher's own-write hash
// stamp introduced in a later checkpoint) applies to autosave
// automatically.
match self.save_buffer() {
Ok(()) => {
self.autosave_pending_since = None;
self.flash("Autosaved", MessageKind::Success);
}
Err(e) => {
// Back off so we don't hammer a failing write every tick.
// The user's edits stay in the in-memory buffer; the
// next edit re-arms the timer for another attempt.
self.autosave_pending_since = None;
tracing::warn!(error = %e, "autosave failed");
self.notify(format!("Autosave failed: {e}"), ModalKind::Error);
}
}
}
/// The instant at which the run loop must wake to fire the pending
/// autosave, if any. Contributes to [`App::next_deadline`] so the
/// `recv_timeout` blocks exactly long enough — no idle CPU.
pub(super) fn autosave_deadline(&self) -> Option<Instant> {
if self.editor.mode == crate::editor::Mode::Diff || self.editor.substitute_preview.is_some()
{
return None;
}
let since = self.autosave_pending_since?;
if !self.config.editor.autosave_enabled {
return None;
}
Some(since + Duration::from_millis(self.config.editor.autosave_idle_ms))
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::app::test_utils::make_app;
use crate::config::Action;
use crate::document::Buffer;
use crate::editor::edit_ops;
/// Enable autosave (off by default) and force the debounce window to
/// a known short value so tests can advance past it with `sleep`.
/// Returns the configured duration so callers can `sleep` for it + a
/// small jitter.
fn shrink_window(app: &mut App) -> Duration {
app.config.editor.autosave_enabled = true;
app.config.editor.autosave_idle_ms = 25;
Duration::from_millis(25)
}
fn dirty_edit(app: &mut App) {
// Bump the version directly via a buffer edit so the test
// doesn't depend on the action dispatch path.
let len = app.editor.buffer.len_chars();
app.editor.buffer.insert_char(len, 'x');
app.editor.dirty = true;
}
#[test]
fn fresh_app_with_no_dirty_buffer_is_a_noop() {
let mut app = make_app();
app.tick_autosave();
assert!(app.autosave_pending_since.is_none());
}
#[test]
fn dirtying_an_unnamed_buffer_does_not_arm_the_timer() {
// `make_app` builds an app with no path; autosave must silently
// skip until a path is associated.
let mut app = make_app();
assert!(app.editor.buffer.path().is_none());
dirty_edit(&mut app);
app.tick_autosave();
assert!(
app.autosave_pending_since.is_none(),
"unnamed buffers must skip autosave silently"
);
}
#[test]
fn dirtying_a_named_buffer_arms_the_timer() {
let mut app = make_app();
// Swap in a buffer associated with a temp path so autosave is
// eligible. Use `tempfile` to keep the test hermetic.
let tmp = tempfile::NamedTempFile::new().expect("temp file");
app.editor.buffer = Buffer::for_new_file(tmp.path());
app.config.editor.autosave_enabled = true;
dirty_edit(&mut app);
app.tick_autosave();
assert!(
app.autosave_pending_since.is_some(),
"dirtying a named buffer must arm the debounce timer"
);
}
#[test]
fn autosave_fires_after_idle_window_elapses() {
let mut app = make_app();
let tmp = tempfile::NamedTempFile::new().expect("temp file");
let path = tmp.path().to_owned();
app.editor.buffer = Buffer::for_new_file(&path);
let window = shrink_window(&mut app);
dirty_edit(&mut app);
app.tick_autosave(); // arm
assert!(app.editor.dirty);
std::thread::sleep(window + Duration::from_millis(20));
app.tick_autosave();
assert!(!app.editor.dirty, "buffer must be clean after autosave");
assert!(app.autosave_pending_since.is_none());
let on_disk = std::fs::read_to_string(&path).expect("read back");
assert!(
on_disk.ends_with('x'),
"autosaved contents must reach the file"
);
let msg = app.transient.as_ref().expect("Autosaved flash recorded");
assert_eq!(msg.text, "Autosaved");
assert!(matches!(msg.kind, MessageKind::Success));
}
#[test]
fn fresh_edit_restarts_the_debounce_window() {
let mut app = make_app();
let tmp = tempfile::NamedTempFile::new().expect("temp file");
app.editor.buffer = Buffer::for_new_file(tmp.path());
let window = shrink_window(&mut app);
dirty_edit(&mut app);
app.tick_autosave();
let first = app.autosave_pending_since.expect("armed");
// Sleep less than the window, then dirty again — the timer must
// restart, not fire.
std::thread::sleep(window / 3);
dirty_edit(&mut app);
app.tick_autosave();
let second = app.autosave_pending_since.expect("still armed");
assert!(
second > first,
"follow-up edit must push the debounce window forward"
);
assert!(app.editor.dirty, "no autosave should have fired yet");
}
#[test]
fn disabling_autosave_skips_the_save() {
let mut app = make_app();
let tmp = tempfile::NamedTempFile::new().expect("temp file");
app.editor.buffer = Buffer::for_new_file(tmp.path());
let window = shrink_window(&mut app);
app.config.editor.autosave_enabled = false;
dirty_edit(&mut app);
app.tick_autosave();
assert!(
app.autosave_pending_since.is_none(),
"disabled autosave must not arm the timer"
);
std::thread::sleep(window + Duration::from_millis(20));
app.tick_autosave();
assert!(app.editor.dirty, "buffer must remain dirty when disabled");
}
#[test]
fn disabling_autosave_after_arming_cancels_pending_save() {
// Regression: previously `tick_autosave` only consulted the
// `enabled` flag on the arm branch. If the user disabled
// autosave after the timer was already armed, any tick after
// the window elapsed would still call `save_file()`.
let mut app = make_app();
let tmp = tempfile::NamedTempFile::new().expect("temp file");
app.editor.buffer = Buffer::for_new_file(tmp.path());
let window = shrink_window(&mut app);
dirty_edit(&mut app);
app.tick_autosave();
assert!(app.autosave_pending_since.is_some(), "armed");
app.config.editor.autosave_enabled = false;
std::thread::sleep(window + Duration::from_millis(20));
app.tick_autosave();
assert!(
app.autosave_pending_since.is_none(),
"pending timer must be cleared when autosave is disabled"
);
assert!(
app.editor.dirty,
"buffer must remain dirty when autosave was disabled before firing"
);
}
#[test]
fn real_edit_dispatch_arms_the_autosave_timer() {
// The other tests poke `buffer.insert_char` + `editor.dirty`
// directly to keep the autosave logic in isolation. This one
// exercises the *real* edit path (`edit_ops::apply` with an
// `InsertChar` action) to verify that `dirty` and
// `Buffer::version()` move in lockstep — otherwise autosave
// would silently fail to arm in production.
let mut app = make_app();
let tmp = tempfile::NamedTempFile::new().expect("temp file");
app.editor.buffer = Buffer::for_new_file(tmp.path());
app.config.editor.autosave_enabled = true;
// Move out of Preview so InsertChar actually types instead of
// just switching modes.
app.editor.mode = crate::editor::Mode::Rendered;
let version_before = app.editor.buffer.version();
edit_ops::apply(&mut app.editor, Action::InsertChar('a'), 24, 80);
assert!(
app.editor.dirty,
"edit_ops::apply(InsertChar) must set dirty"
);
assert_ne!(
app.editor.buffer.version(),
version_before,
"edit_ops::apply(InsertChar) must bump Buffer::version()"
);
app.tick_autosave();
assert!(
app.autosave_pending_since.is_some(),
"tick_autosave must arm the debounce timer after a real edit",
);
}
#[test]
fn diff_mode_clears_pending_autosave_and_skips() {
// Diff mode is review-in-progress: an armed timer must be
// disarmed and no save must fire while reviewing.
let mut app = make_app();
let tmp = tempfile::NamedTempFile::new().expect("temp file");
app.editor.buffer = Buffer::for_new_file(tmp.path());
let window = shrink_window(&mut app);
dirty_edit(&mut app);
app.tick_autosave();
assert!(app.autosave_pending_since.is_some(), "armed");
// Flip into diff mode and let the window elapse.
app.editor.mode = crate::editor::Mode::Diff;
std::thread::sleep(window + Duration::from_millis(20));
app.tick_autosave();
assert!(
app.autosave_pending_since.is_none(),
"diff mode must clear the armed timer",
);
assert!(
app.editor.dirty,
"buffer must remain dirty (no save fired in diff mode)",
);
assert!(
app.autosave_deadline().is_none(),
"deadline suppressed in diff mode"
);
}
#[test]
fn live_substitute_preview_suspends_autosave() {
// A `:s` preview transiently rewrites the buffer through raw
// `Buffer` edits (version bumps, `dirty` untouched); on an
// already-dirty buffer an armed timer must be disarmed and no
// save may fire while the preview is active — otherwise preview
// text would reach the disk.
let mut app = make_app();
let tmp = tempfile::NamedTempFile::new().expect("temp file");
let path = tmp.path().to_owned();
app.editor.buffer = Buffer::for_new_file(&path);
let window = shrink_window(&mut app);
dirty_edit(&mut app); // appends 'x'
app.tick_autosave();
assert!(app.autosave_pending_since.is_some(), "armed");
crate::editor::vim_ops::update_substitute_preview(&mut app.editor, "%s/x/y/", None, 24, 80);
assert!(app.editor.substitute_preview.is_some(), "preview active");
std::thread::sleep(window + Duration::from_millis(20));
app.tick_autosave();
assert!(
app.autosave_pending_since.is_none(),
"an active preview must clear the armed timer"
);
assert!(app.editor.dirty, "no save fired during the preview");
assert!(
app.autosave_deadline().is_none(),
"deadline suppressed during the preview"
);
let on_disk = std::fs::read_to_string(&path).unwrap_or_default();
assert!(
!on_disk.contains('y'),
"preview text must never reach the disk"
);
}
#[test]
fn deadline_is_none_when_no_edit_pending() {
let app = make_app();
assert!(app.autosave_deadline().is_none());
}
#[test]
fn deadline_is_none_when_disabled_even_if_armed() {
// Pathological state: timer was armed when autosave was on,
// then the user toggled the setting off. The deadline must
// not contribute to next_deadline anymore.
let mut app = make_app();
app.autosave_pending_since = Some(Instant::now());
app.config.editor.autosave_enabled = false;
assert!(app.autosave_deadline().is_none());
}
}