From 75ed63e85f56dba50331a534c98af95e10ac50c1 Mon Sep 17 00:00:00 2001 From: foxnne Date: Sun, 18 Oct 2026 17:59:12 -0500 Subject: [PATCH] app: a demo gives back a folder opened from a zip's bytes A demo sets the person's session aside and reopens it when it ends, by path. A document whose path cannot open it again was lost: a file the web page fetched or the person uploaded, and a zip opened as the project folder, whose tab is its mount. That is fizzyed.it's `?open=/site.zip`: after the tour, the site was gone. Now `begin` keeps the bytes of each document its path cannot reopen (`Plugin.documentBytes`), and `end` opens those first, so a zip mounts again before the folder and the files inside it are opened. The bundled-demos test opens a zip from its bytes first and checks it is mounted and the project folder again once every demo has played; the test build bundles the archive plugin for it. Part of fizzyedit/plans#30 (step 0). Co-Authored-By: Claude Opus 5.5 --- build/app.zig | 7 +++++ src/editor/Demo.zig | 70 ++++++++++++++++++++++++++++++++++++--------- tests/demos.zig | 37 +++++++++++++++++++++++- 3 files changed, 100 insertions(+), 14 deletions(-) diff --git a/build/app.zig b/build/app.zig index 34aa2bf80..eee68fb2e 100644 --- a/build/app.zig +++ b/build/app.zig @@ -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, &.{ @@ -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) { diff --git a/src/editor/Demo.zig b/src/editor/Demo.zig index 63fc1847d..998fefd29 100644 --- a/src/editor/Demo.zig +++ b/src/editor/Demo.zig @@ -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, @@ -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. @@ -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. @@ -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); @@ -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); diff --git a/tests/demos.zig b/tests/demos.zig index 973bfcb3b..34f176f43 100644 --- a/tests/demos.zig +++ b/tests/demos.zig @@ -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); @@ -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); }