Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs/PLUGINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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).

Expand Down
7 changes: 7 additions & 0 deletions plugins/archive/plugin.zig
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const vtable: sdk.Plugin.VTable = .{
.encodeSnapshot = encodeSnapshot,
.releaseSnapshot = releaseSnapshot,
.documentWritten = documentWritten,
.documentBytes = documentBytes,
};

comptime {
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions plugins/text/plugin.zig
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const vtable: sdk.Plugin.VTable = .{
.encodeSnapshot = encodeSnapshot,
.releaseSnapshot = releaseSnapshot,
.documentWritten = documentWritten,
.documentBytes = documentBytes,
.documentDefaultSaveAsFilename = documentDefaultSaveAsFilename,
};

Expand Down Expand Up @@ -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));
Expand Down
12 changes: 5 additions & 7 deletions plugins/text/src/Document.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
27 changes: 25 additions & 2 deletions sdk/src/Plugin.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion sdk/src/version.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions src/editor/Editor.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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);
Expand Down
57 changes: 56 additions & 1 deletion tests/integration.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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);
Expand Down
Loading