From 67979be0b11a93718ec3a0662a5f3884593ce634 Mon Sep 17 00:00:00 2001 From: foxnne Date: Sun, 18 Oct 2026 18:36:33 -0500 Subject: [PATCH 1/2] text: a crash checkpoint no longer splits a run of typing The crash checkpoint (`Checkpoint`) captures every dirty document every few seconds of typing, and for its unsaved bytes asks the owner for the bytes a save would write (`documentBytes`). The text plugin's `savedBytes` closed the undo group there, as if it were the save, so a run of typing was cut into a new undo step each time a checkpoint landed in it. It is also what made the tour test's step check flaky: on a slow runner a checkpoint fell between the two keystrokes it types, and the second came as `.added`, not `.joined`. The save's undo stop now comes when its write lands (`written`), which only a real save reports, and only while the written state is still the top step. The test takes a checkpoint between its keystrokes itself, so it holds whatever a frame takes, and checks that a save still stops the run. Co-Authored-By: Claude Opus 5.5 --- plugins/text/src/Document.zig | 9 ++++++++- tests/integration.zig | 27 +++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) 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); From fe3ad107fa9be5a273724be795453ae6eef1c817 Mon Sep 17 00:00:00 2001 From: foxnne Date: Sun, 18 Oct 2026 19:36:39 -0500 Subject: [PATCH 2/2] sdk: a document's bytes for a checkpoint, without a save's bookkeeping The host copied a document for things that are not saves (the crash checkpoint, a restart or plugin reload, a tape's file, a fizzy.document.changed listener) through the save's own snapshot, so each copy did what a save's start does. A new optional Plugin hook, documentBytes, gives the bytes a save would write with none of that; Plugin.documentBytes uses it and falls back to the snapshot pair. Saves keep the snapshot: DocumentIo, and the web download through the new Plugin.saveBytes. The text and archive plugins implement it. In text, a checkpoint no longer runs format on save, and a checkpoint during a save's write no longer moves the edit that write cleans up to. Co-Authored-By: Claude Opus 5.5 --- docs/PLUGINS.md | 10 +++--- plugins/archive/plugin.zig | 7 +++++ plugins/text/plugin.zig | 7 +++++ plugins/text/src/Document.zig | 12 +++----- sdk/src/Plugin.zig | 27 +++++++++++++++-- sdk/src/version.zig | 2 +- src/editor/Editor.zig | 6 ++-- tests/integration.zig | 57 ++++++++++++++++++++++++++++++++++- 8 files changed, 110 insertions(+), 18 deletions(-) diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 079218d8f..2dfc2eea5 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -725,7 +725,9 @@ gets the same, and `DropZones.wheel`/`at`/`draw` give a plugin the same drop for fires). - **Saving** — `documentSnapshot` / `encodeSnapshot` / `releaseSnapshot` (a snapshot on the UI thread, encoded on Fizzy's worker), `documentWritten`, and `saveQuestion` (asked before a save, - e.g. "flatten layers?"). Fizzy runs every save itself. + e.g. "flatten layers?"). Fizzy runs every save itself. `documentBytes` is the same bytes for a + copy that is not a save (a crash checkpoint, a restart, a tape's file), taken with none of a + save's bookkeeping; without it Fizzy takes a snapshot and encodes it. - Folder and file changes are **events**, not hooks (§3.10a): `fizzy.folder.opened` / `.closed`, `fizzy.files.changed`, `fizzy.document.changed`. - **Contributions** — `contributeMenu`, `contributeKeybinds`. @@ -787,9 +789,9 @@ A path can be on the disk, inside a zip the user opened, or on a cloud drive a p the same code for all three. For *documents* the same is true once an owner implements the storage-agnostic pair: -- `documentBytes(doc, allocator) → []u8` — the saved form. The host writes it wherever the - path's filesystem is, with a modified-time precondition so an edit made elsewhere is a - conflict rather than an overwrite. +- `documentSnapshot` / `encodeSnapshot` — the saved form, taken as the save starts and encoded + on a worker. The host writes it wherever the path's filesystem is, with a modified-time + precondition so an edit made elsewhere is a conflict rather than an overwrite. - `documentWritten(doc, path)` — the write landed; clear dirty state, and adopt `path` if it is not the document's own (Save As). diff --git a/plugins/archive/plugin.zig b/plugins/archive/plugin.zig index dba78ea01..6d33da0f6 100644 --- a/plugins/archive/plugin.zig +++ b/plugins/archive/plugin.zig @@ -57,6 +57,7 @@ const vtable: sdk.Plugin.VTable = .{ .encodeSnapshot = encodeSnapshot, .releaseSnapshot = releaseSnapshot, .documentWritten = documentWritten, + .documentBytes = documentBytes, }; comptime { @@ -288,6 +289,12 @@ fn releaseSnapshot(_: *anyopaque, snapshot: *anyopaque) void { snap.allocator.free(snap.bytes); snap.allocator.destroy(snap); } +/// The archive packed as it stands, for a copy that is not a save: no generation remembered, so a +/// save in flight still cleans up to its own. +fn documentBytes(_: *anyopaque, handle: DocHandle, _: []const u8, allocator: std.mem.Allocator) anyerror![]u8 { + const doc = docFrom(handle) orelse return error.DocumentNotFound; + return doc.pack(allocator); +} fn documentWritten(state: *anyopaque, handle: DocHandle, path: []const u8) anyerror!void { const doc = docFrom(handle) orelse return error.DocumentNotFound; if (!std.mem.eql(u8, path, doc.path)) try setDocumentPath(state, handle, path); diff --git a/plugins/text/plugin.zig b/plugins/text/plugin.zig index 912f4ba09..7a659ef2a 100644 --- a/plugins/text/plugin.zig +++ b/plugins/text/plugin.zig @@ -77,6 +77,7 @@ const vtable: sdk.Plugin.VTable = .{ .encodeSnapshot = encodeSnapshot, .releaseSnapshot = releaseSnapshot, .documentWritten = documentWritten, + .documentBytes = documentBytes, .documentDefaultSaveAsFilename = documentDefaultSaveAsFilename, }; @@ -482,6 +483,12 @@ fn documentWritten(_: *anyopaque, handle: DocHandle, path: []const u8) anyerror! const doc = docFrom(handle) orelse return error.DocumentNotFound; try doc.written(path); } +/// The text as it stands, for a copy that is not a save (a crash checkpoint, a restart): not +/// formatted, and with no edit remembered, so a save in flight still cleans up to its own. +fn documentBytes(_: *anyopaque, handle: DocHandle, _: []const u8, allocator: std.mem.Allocator) anyerror![]u8 { + const doc = docFrom(handle) orelse return error.DocumentNotFound; + return allocator.dupe(u8, doc.text.items); +} fn documentDefaultSaveAsFilename(_: *anyopaque, handle: DocHandle, allocator: std.mem.Allocator) anyerror![]const u8 { const doc = docFrom(handle) orelse return error.DocumentNotFound; return allocator.dupe(u8, std.fs.path.basename(doc.path)); diff --git a/plugins/text/src/Document.zig b/plugins/text/src/Document.zig index efb3e9d46..ab1dfc52c 100644 --- a/plugins/text/src/Document.zig +++ b/plugins/text/src/Document.zig @@ -397,13 +397,11 @@ pub fn save(self: *Document) !void { self.notifyContentChanged(); } -/// 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`). +/// The bytes `save` would write, for a host that does the writing itself (a mounted drive), as a +/// save starts. 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. The run of typing stays open until +/// then: the save's undo stop is made when its write lands (`written`). A copy that is no save (a +/// crash checkpoint) takes `text` instead (the plugin's `documentBytes`), remembering nothing. pub fn savedBytes(self: *Document, allocator: std.mem.Allocator) ![]u8 { self.serialized_op_id = self.history.topOpId(); return allocator.dupe(u8, self.text.items); diff --git a/sdk/src/Plugin.zig b/sdk/src/Plugin.zig index b9d0189da..e46eb4152 100644 --- a/sdk/src/Plugin.zig +++ b/sdk/src/Plugin.zig @@ -202,6 +202,15 @@ pub const VTable = struct { /// stood for, as `saveDocument` would; when `path` differs from the document's own, adopt it /// (Save As). documentWritten: ?*const fn (state: *anyopaque, doc: DocHandle, path: []const u8) anyerror!void = null, + /// On the UI thread: `doc` as it stands, encoded as `path`'s format, the bytes a save would + /// write, for a copy that is not a save. fizzy takes these for a crash checkpoint (every few + /// seconds while someone types), for a restart or a plugin reload carrying unsaved work, for a + /// tape's file, and for a `fizzy.document.changed` listener. So none of a save's bookkeeping + /// or side effects: no format on save, no edit remembered for `documentWritten`, no undo stop, + /// nothing the person sees change. Owned by the caller, allocated with `allocator`. Absent = + /// fizzy takes the save's two halves instead (`documentSnapshot` + `encodeSnapshot`), and + /// whatever those do to the document happens on every checkpoint too. + documentBytes: ?*const fn (state: *anyopaque, doc: DocHandle, path: []const u8, allocator: std.mem.Allocator) anyerror![]u8 = null, closeDocument: ?*const fn (state: *anyopaque, doc: DocHandle) void = null, /// Reload `doc` from its on-disk path, replacing in-memory contents and clearing dirty /// state / undo history as appropriate. Called by fizzy's document watcher when a @@ -582,9 +591,23 @@ pub fn bytesPath(self: Plugin, doc: DocHandle, allocator: std.mem.Allocator) ![] return allocator.dupe(u8, self.documentPath(doc)); } -/// Both halves at once, on the calling thread: for the few who need the bytes now (a crash -/// recovery file, the browser's download). Null as `documentSnapshot`. +/// `doc`'s bytes as a save to `path` would write them, now, for a copy that is not a save (a crash +/// recovery file, a restart's kept documents, a tape's file): the owner's `documentBytes`, which +/// leaves the document as it was. An owner without it gives the two halves of a save +/// (`saveBytes`), bookkeeping included. Null when it can give neither. pub fn documentBytes(self: Plugin, doc: DocHandle, path: []const u8, allocator: std.mem.Allocator) !?[]u8 { + if (self.vtable.documentBytes) |f| { + const prof = core.profile.begin(self.id, "documentBytes"); + defer prof.end(); + return f(self.state, doc, path, allocator) catch |err| self.failed("documentBytes", err); + } + return self.saveBytes(doc, path, allocator); +} + +/// Both halves of a save at once, on the calling thread, for a save that needs its bytes now (the +/// browser's download). The owner remembers the edit they stand for, as at any save's start, so +/// `documentWritten` must follow once they are written. Null as `documentSnapshot`. +pub fn saveBytes(self: Plugin, doc: DocHandle, path: []const u8, allocator: std.mem.Allocator) !?[]u8 { const snapshot = (try self.documentSnapshot(doc, path, allocator)) orelse return null; defer self.releaseSnapshot(snapshot); return try self.encodeSnapshot(snapshot, path, allocator); diff --git a/sdk/src/version.zig b/sdk/src/version.zig index c7393d891..fe7c028de 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0x1dd3a67d781503a7; +pub const recorded_sdk_shape_fingerprint: u64 = 0x85211a2b2e7bb433; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index b6b688942..75d8bd4a3 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -6361,7 +6361,7 @@ pub fn documentSaved(editor: *Editor, doc: sdk.DocHandle) void { } /// Whether the host writes this document (`DocumentIo`) rather than its owner: always on a -/// mount, and on the disk whenever the owner can serialize. An owner without `documentBytes` +/// mount, and on the disk whenever the owner can serialize. An owner without `documentSnapshot` /// writes its own files — and can only ever reach the disk. pub fn hostWrites(editor: *Editor, doc: sdk.DocHandle) bool { if (editor.doc_io.owns(doc.owner.documentPath(doc))) return true; @@ -6370,7 +6370,7 @@ pub fn hostWrites(editor: *Editor, doc: sdk.DocHandle) bool { } /// Save through the host: its owner serializes, the path's filesystem writes, and the owner -/// hears back when the write lands. An owner without `documentBytes` cannot save to a mount +/// hears back when the write lands. An owner without `documentSnapshot` cannot save to a mount /// at all, which is said once rather than failing silently. fn saveThroughHost(editor: *Editor, doc: sdk.DocHandle) !void { editor.doc_io.save(doc, doc.owner.documentPath(doc)) catch |err| switch (err) { @@ -6387,7 +6387,7 @@ fn saveThroughHost(editor: *Editor, doc: sdk.DocHandle) !void { /// then tell the owner it was written under that name. fn downloadDocument(editor: *Editor, doc: sdk.DocHandle, path: []const u8) !void { if (comptime builtin.target.cpu.arch != .wasm32) return error.Unsupported; - const bytes = (try doc.owner.documentBytes(doc, path, editor.app.gpa)) orelse return error.Unsupported; + const bytes = (try doc.owner.saveBytes(doc, path, editor.app.gpa)) orelse return error.Unsupported; defer editor.app.gpa.free(bytes); try dvui.backend.downloadData(std.fs.path.basename(path), bytes); try doc.owner.documentWritten(doc, path); diff --git a/tests/integration.zig b/tests/integration.zig index ef741fc05..e0b67ffc5 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -8691,7 +8691,7 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes 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; + const bytes = (try c.owner.saveBytes(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)); @@ -8702,6 +8702,61 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expectEqualStrings("a", try textOf(host, c)); try std.testing.expect(!c.owner.isDirty(c)); + // A checkpoint is no save (`Plugin.documentBytes`): with format on save on, it keeps the + // text as typed and leaves the document alone; the save itself formats. + const d = try newDoc(editor, host, c.id); + { + const Formatter = struct { + fn supports(_: *anyopaque, _: []const u8) bool { + return true; + } + fn format(_: *anyopaque, _: []const u8, _: []const u8, _: []const u8) ?[]const u8 { + return "formatted"; + } + const vtable: sdk.LanguageSupport.VTable = .{ .supportsFormat = supports, .format = format }; + }; + try host.registerLanguageSupport(.{ .id = "test-format", .vtable = &Formatter.vtable, .owner = &md_zig_plugin }); + defer _ = host.language_support.pop(); + const text_settings = &@import("text").statePtr().settings; + text_settings.format_on_save.set(true); + defer text_settings.format_on_save.set(false); + try dvui.testing.writeText("as typed"); + try dvui.testing.settle(headlessFrame); + { + var kept = try fizzy.Editor.KeptDocuments.capture(editor, null); + defer kept.deinit(); + const unsaved = for (kept.docs.items) |k| { + if (std.mem.eql(u8, k.path, d.owner.documentPath(d))) break k.unsaved orelse return error.TestUnexpectedResult; + } else return error.TestUnexpectedResult; + try std.testing.expectEqualStrings("as typed", unsaved); + } + try std.testing.expectEqualStrings("as typed", try textOf(host, d)); + const formatted = (try d.owner.saveBytes(d, d.owner.documentPath(d), gpa)) orelse return error.TestUnexpectedResult; + defer gpa.free(formatted); + try std.testing.expectEqualStrings("formatted", formatted); + try d.owner.documentWritten(d, d.owner.documentPath(d)); + try dvui.testing.settle(headlessFrame); + } + + // A checkpoint while a save's write is in flight leaves the save's edit as it was: the + // write lands, and the keystroke typed after its snapshot is still unsaved. + const e = try newDoc(editor, host, d.id); + try dvui.testing.writeText("a"); + try dvui.testing.settle(headlessFrame); + const in_flight = (try e.owner.saveBytes(e, e.owner.documentPath(e), gpa)) orelse return error.TestUnexpectedResult; + defer gpa.free(in_flight); + try dvui.testing.writeText("b"); + try dvui.testing.settle(headlessFrame); + { + var kept = try fizzy.Editor.KeptDocuments.capture(editor, null); + kept.deinit(); + } + try e.owner.documentWritten(e, e.owner.documentPath(e)); + try std.testing.expectEqualStrings("a", in_flight); + try std.testing.expect(e.owner.isDirty(e)); + + try editor.rawCloseFileID(e.id); + try editor.rawCloseFileID(d.id); try editor.rawCloseFileID(c.id); try editor.rawCloseFileID(b.id); try editor.rawCloseFileID(a.id);