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
7 changes: 7 additions & 0 deletions build/app.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1166,6 +1166,12 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.
.core = core_module_test,
.sdk = sdk_module_test,
}, fizzy_test_module);
// A `.zip` opened as a mounted folder, as the web opens the site (`tests/demos.zig`).
const archive_module_test = plugins.archive.addStaticModule(b, target, optimize, .{
.dvui = dvui_testing_dep.module("dvui_testing"),
.core = core_module_test,
.sdk = sdk_module_test,
}, fizzy_test_module);
// What the test build bundles, as the exe's does (`build/exe.zig`), so a test can bring up
// the whole editor — `Editor.postInit` registers whatever is listed here.
fizzy_test_module.addImport("bundled_plugins", sdk.bundledPluginsModule(b, target, optimize, &.{
Expand All @@ -1174,6 +1180,7 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.
.{ .name = "image", .module = image_module_test },
.{ .name = "markdown", .module = markdown_module_test },
.{ .name = "devtools", .module = devtools_module_test },
.{ .name = "archive", .module = archive_module_test },
}));

if (target.result.os.tag == .macos) {
Expand Down
70 changes: 57 additions & 13 deletions src/editor/Demo.zig
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
//! A demo plays inside the user's own copy of fizzy, so it must not cost them anything: `begin`
//! sets their session aside (project folder, open documents) and flushes any pending layout and
//! settings writes, nothing is persisted while the demo is loaded (`active`), and `end` closes the
//! demo's documents, reloads the layout from `layout.zon` and reopens what the user had. A demo
//! will not start over unsaved changes.
//! demo's documents, reloads the layout from `layout.zon` and reopens what the user had: from its
//! path, or from the bytes `begin` kept of one its path cannot open again (a file the web page
//! fetched, a zip whose folder is the project). A demo will not start over unsaved changes.
//!
//! **Snapshots** (`automation.Stage.capture`) hold what a scene changes as it plays: the mounted
//! files as saved, each open document's state from its owner (`captureDocumentState` — contents,
Expand Down Expand Up @@ -98,8 +99,16 @@ const Pending = union(enum) {

const Saved = struct {
folder: ?[]u8,
/// Open documents' paths, the active one last so it is focused again.
docs: [][]u8,
/// Open documents, the active one last so it is focused again.
docs: []Doc,

const Doc = struct {
path: []u8,
/// What it was opened from, when its path cannot open it again (`reopenable`): a file the
/// web page fetched or the person uploaded, a zip whose folder is the project. Null: it
/// reopens from its path.
bytes: ?[]u8 = null,
};
};

/// Inert until `attach`: nothing loaded, so nothing in `tick` acts on it.
Expand Down Expand Up @@ -441,16 +450,23 @@ fn begin(ctx: *anyopaque, _: *const automation.Tape) void {
self.saveSettings();

const gpa = editor.app.gpa;
var docs: std.ArrayList([]u8) = .empty;
var docs: std.ArrayList(Saved.Doc) = .empty;
const active_id = if (editor.activeDoc()) |d| d.id else null;
var active_path: ?[]u8 = null;
var active_doc: ?Saved.Doc = null;
for (editor.app.open_files.values()) |doc| {
const path = doc.owner.documentPath(doc);
if (path.len == 0 or std.mem.startsWith(u8, path, "demo://")) continue;
const copy = gpa.dupe(u8, path) catch continue;
if (active_id == doc.id) active_path = copy else docs.append(gpa, copy) catch gpa.free(copy);
// Never saved (`untitled-1`): no file, and clean, since `mayStart` refused unsaved work, so
// nothing to give back. Opened from its bytes it would come back as a file of that name.
if (!reopenable(editor, path) and !doc.owner.documentHasRecognizedSaveExtension(doc)) continue;
var saved: Saved.Doc = .{ .path = gpa.dupe(u8, path) catch continue };
if (!reopenable(editor, path)) {
saved.bytes = doc.owner.documentBytes(doc, path, gpa) catch null;
if (saved.bytes == null) dvui.log.warn("demo: {s} cannot be opened again after the demo: its owner gives no bytes for it", .{path});
}
if (active_id == doc.id) active_doc = saved else docs.append(gpa, saved) catch freeSavedDoc(gpa, saved);
}
if (active_path) |p| docs.append(gpa, p) catch gpa.free(p);
if (active_doc) |d| docs.append(gpa, d) catch freeSavedDoc(gpa, d);
// Nor is a demo's folder: one demo straight after another finds the last one's folder still
// open — giving the user theirs back by closing it is deferred to the next frame
// (`App.closeProjectFolder`) — and must not give it to them at its end.
Expand Down Expand Up @@ -482,14 +498,27 @@ fn end(ctx: *anyopaque) void {
editor.command_palette.finishClose();

const saved = self.saved orelse return;
// What was opened from bytes first: a zip among them mounts again, so the folder and the
// files opened from inside it can be opened after it.
for (saved.docs) |d| {
const bytes = d.bytes orelse continue;
const path = gpa.dupe(u8, d.path) catch continue;
_ = editor.openFileFromBytes(path, bytes, editor.workbench.currentGroupingID()) catch |err|
dvui.log.warn("demo: could not reopen {s}: {t}", .{ d.path, err });
}
if (saved.folder) |f| {
editor.setProjectFolder(f) catch |err| dvui.log.warn("demo: could not reopen {s}: {t}", .{ f, err });
} else {
editor.app.host.closeProjectFolder();
}
for (saved.docs) |path| {
_ = editor.openFilePath(path, editor.workbench.currentGroupingID()) catch |err|
dvui.log.warn("demo: could not reopen {s}: {t}", .{ path, err });
for (saved.docs) |d| {
if (d.bytes != null) continue;
_ = editor.openFilePath(d.path, editor.workbench.currentGroupingID()) catch |err|
dvui.log.warn("demo: could not reopen {s}: {t}", .{ d.path, err });
}
// The active one last, whichever way it came back.
if (saved.docs.len > 0) {
if (openIndex(editor, saved.docs[saved.docs.len - 1].path)) |index| editor.workbench.setActiveDocIndex(index);
}
self.freeSaved();
dvui.refresh(null, @src(), null);
Expand All @@ -499,11 +528,26 @@ fn freeSaved(self: *Demo) void {
const gpa = self.editor.app.gpa;
const saved = self.saved orelse return;
if (saved.folder) |f| gpa.free(f);
for (saved.docs) |d| gpa.free(d);
for (saved.docs) |d| freeSavedDoc(gpa, d);
gpa.free(saved.docs);
self.saved = null;
}

fn freeSavedDoc(gpa: std.mem.Allocator, d: Saved.Doc) void {
gpa.free(d.path);
if (d.bytes) |b| gpa.free(b);
}

/// Whether `path` opens again from where it is: a file on a mount (one the demo closes comes back
/// when what mounted it does), or on disk. The web has no disk: what is not on a mount there was
/// fetched or uploaded.
fn reopenable(editor: *Editor, path: []const u8) bool {
if (editor.app.file_table.isMounted(path)) return true;
if (comptime builtin.target.cpu.arch == .wasm32) return false;
std.Io.Dir.cwd().access(dvui.io, path, .{}) catch return false;
return true;
}

/// Put fizzy into `kf` outright: nothing the demo did before survives.
fn keyframe(ctx: *anyopaque, kf: *const automation.Tape.Keyframe) void {
const self = from(ctx);
Expand Down
37 changes: 36 additions & 1 deletion tests/demos.zig
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,21 @@ test "demo: the rail's Files anchor the scripts spell out is the one fizzy publi
try std.testing.expectEqualStrings("fizzy.rail:" ++ workbench.view_files, fizzy.Editor.Demo.catalog.files_icon);
}

test "demo: every bundled demo plays to the end through fizzy's stage, and a seek lands on what playing reached" {
test "demo: every bundled demo plays to the end through fizzy's stage, a seek lands on what playing reached, and the person's own folder is theirs again after" {
var app: BundledApp = undefined;
try app.up();
defer app.down();
const p = &app.editor.demo.player;
const gpa = std.testing.allocator;

// What the person had before the demos: a folder opened from a zip's bytes, as the web's
// `?open=/site.zip` opens the site. There is no file to open it from again.
const site = try openZipFromBytes(app.editor, "site.zip", &.{.{ "README.md", "# The site\n" }});
defer gpa.free(site);
const docs_before = app.editor.app.open_files.count();
const active_before = try gpa.dupe(u8, activePath(app.editor));
defer gpa.free(active_before);
try std.testing.expectEqualStrings("site.zip", active_before);
var moments: std.ArrayList(DemoMoment) = .empty;
defer moments.deinit(gpa);
var prng: std.Random.DefaultPrng = .init(bundled_seek_seed);
Expand Down Expand Up @@ -131,4 +140,30 @@ test "demo: every bundled demo plays to the end through fizzy's stage, and a see
}
p.unload();
try dvui.testing.settle(headlessFrame);

try std.testing.expectEqualStrings(site, app.editor.app.folder orelse "");
try std.testing.expectEqual(docs_before, app.editor.app.open_files.count());
try std.testing.expect(app.editor.app.host.files.?.isMounted(site));
try std.testing.expectEqualStrings(active_before, activePath(app.editor));
}

fn activePath(editor: *fizzy.Editor) []const u8 {
const doc = editor.activeDoc() orelse return "";
return doc.owner.documentPath(doc);
}

/// Open a zip of `files` from its bytes, as the web page opens one it fetched
/// (`FizzyWebOpenBytes`), and return the folder it mounted as, in `std.testing.allocator`.
fn openZipFromBytes(editor: *fizzy.Editor, name: []const u8, files: []const struct { []const u8, []const u8 }) ![]u8 {
const gpa = std.testing.allocator;
var mem = try fizzy.core.vfs.Mem.init(gpa);
defer mem.deinit();
for (files) |f| try mem.put(f[0], f[1]);
const bytes = try fizzy.core.vfs.zip.pack(gpa, &mem);
defer gpa.free(bytes);
_ = try editor.openFileFromBytes(try editor.app.gpa.dupe(u8, name), bytes, 0);
try dvui.testing.settle(headlessFrame);
const folder = editor.app.folder orelse return error.NotMounted;
try std.testing.expect(std.mem.startsWith(u8, folder, "zip://"));
return gpa.dupe(u8, folder);
}
Loading