diff --git a/plugins/text/src/Document.zig b/plugins/text/src/Document.zig index 6d2b4c330..efb3e9d46 100644 --- a/plugins/text/src/Document.zig +++ b/plugins/text/src/Document.zig @@ -400,8 +400,11 @@ pub fn save(self: *Document) !void { /// The bytes `save` would write, for a host that does the writing itself (a mounted drive). /// Remembers which edit those bytes stand for: the write lands later, and edits typed in the /// meantime must still count as unsaved when it does. +/// +/// Not a save in itself, so the run of typing stays open: the host takes these bytes for a crash +/// checkpoint too (`KeptDocuments`), every few seconds while someone types, and never writes them +/// over the file. The save's undo stop is made when its write lands (`written`). pub fn savedBytes(self: *Document, allocator: std.mem.Allocator) ![]u8 { - self.history.closeGroup(); self.serialized_op_id = self.history.topOpId(); return allocator.dupe(u8, self.text.items); } @@ -418,6 +421,10 @@ pub fn written(self: *Document, path: []const u8) !void { self.unsaved = false; } self.clean_op_id = self.serialized_op_id; + // The state written is an undo stop, as `save` makes it, while it is still the top. Typed + // while the write was in flight, a keystroke has already joined that step: the step is newer + // than the file, so the document stays dirty, and undo takes it back past the saved state. + if (self.history.topOpId() == self.serialized_op_id) self.history.closeGroup(); self.notifyContentChanged(); } diff --git a/tests/integration.zig b/tests/integration.zig index ac66cdc46..ef741fc05 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -8651,10 +8651,16 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes const a = try newDoc(editor, host, was.id); rec.doc = a.id; - // Typed a keystroke at a time: one step, joined. - for ([_][]const u8{ "a", "b" }) |key| { + // Typed a keystroke at a time: one step, joined. A crash checkpoint between them (one is + // taken every few seconds of typing, by the wall clock: `Checkpoint`) is no save, so the + // run stays open; taken here, it is between them however long a frame takes. + for ([_][]const u8{ "a", "b" }, 0..) |key, i| { try dvui.testing.writeText(key); try dvui.testing.settle(headlessFrame); + if (i == 0) { + var kept = try fizzy.Editor.KeptDocuments.capture(editor, null); + kept.deinit(); + } } try std.testing.expect(host.callCommand("text.insert", ".{ .text = \"X\", .line = 1 }", host.arena()) == .ok); try editor.undo(); @@ -8680,6 +8686,23 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expectError(error.StepDoesNotApply, b.owner.vtable.applyStep.?(b.owner.state, b, rec.effects.items[3], "", 0)); try std.testing.expectEqualStrings("Yab", try textOf(host, b)); + // A save is an undo stop, made when its write lands (`documentWritten`): typing after it + // is a step of its own, and undoing that step comes back to the saved, clean document. + const c = try newDoc(editor, host, b.id); + try dvui.testing.writeText("a"); + try dvui.testing.settle(headlessFrame); + const bytes = (try c.owner.documentBytes(c, c.owner.documentPath(c), gpa)) orelse return error.TestUnexpectedResult; + gpa.free(bytes); + try c.owner.documentWritten(c, c.owner.documentPath(c)); + try std.testing.expect(!c.owner.isDirty(c)); + try dvui.testing.writeText("b"); + try dvui.testing.settle(headlessFrame); + try editor.undo(); + try dvui.testing.settle(headlessFrame); + try std.testing.expectEqualStrings("a", try textOf(host, c)); + try std.testing.expect(!c.owner.isDirty(c)); + + try editor.rawCloseFileID(c.id); try editor.rawCloseFileID(b.id); try editor.rawCloseFileID(a.id); editor.workbench.setActiveDocIndex(editor.app.open_files.getIndex(was.id) orelse return error.TestUnexpectedResult);