diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 767bb1593..82fef918a 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -756,7 +756,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`. @@ -818,9 +820,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 3848e3fa3..e3222e6d3 100644 --- a/plugins/text/plugin.zig +++ b/plugins/text/plugin.zig @@ -78,6 +78,7 @@ const vtable: sdk.Plugin.VTable = .{ .encodeSnapshot = encodeSnapshot, .releaseSnapshot = releaseSnapshot, .documentWritten = documentWritten, + .documentBytes = documentBytes, .documentDefaultSaveAsFilename = documentDefaultSaveAsFilename, }; @@ -487,6 +488,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 42c0ecc39..567618fb2 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 bd99672ef..444665929 100644 --- a/sdk/src/Plugin.zig +++ b/sdk/src/Plugin.zig @@ -220,6 +220,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 @@ -607,9 +616,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 a26dfa022..034cce049 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 = 0xb37557f4594fec7b; +pub const recorded_sdk_shape_fingerprint: u64 = 0xb246a451a6af160e; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index c7139b1da..1159bc12c 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -6383,7 +6383,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; @@ -6392,7 +6392,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) { @@ -6409,7 +6409,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 2463e3098..6549fc819 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -9017,7 +9017,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)); @@ -9028,6 +9028,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);