diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 82119a89e..e0325ffb4 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -55,9 +55,12 @@ While a demo plays: same one a document's history transport hides into), filling with the demo's progress. The pointer held on the dome (or a press on it) grows the bar back out; it goes back soon after the pointer leaves it. Paused or ended it stays out, unless its chevron tab sent it in. -- **Nothing is persisted.** The stage sets the user's session aside when a demo loads (project - folder, open documents, layout as on disk, plugin settings) and gives it back when it is - stopped. A demo refuses to start over unsaved changes. Its files live in memory +- **Nothing is persisted, and nothing of the person's is touched.** A demo plays on a stage of + its own: when it loads, the person's panes are set aside whole and their documents stay open + behind them, hidden — never closed, saved, reloaded or put back by a snapshot, unsaved edits and + history and all — with their project folder, layout as on disk, settings and focus. The demo + opens its own documents in panes of its own, and when it is stopped the person gets back + exactly what they left. So a demo starts over unsaved work. Its files live in memory (`demo://`), so it never touches disk, and its folder never enters Recents. ## How it works diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 82fef918a..54bf1181a 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -1324,7 +1324,7 @@ The SDK ships definitions for the services in this ecosystem, in | `"markdown"` | `markdown` | Render a markdown byte slice into the current dvui parent (native only — absent on web) | | `"wikilink"` | any indexer (e.g. `brain`) | Resolve `[[Note]]` to a file, plus completion candidates and index state | | `"automation"` | the application (fizzy: `app/automation/Service.zig`, over its live driver) | `play` a live tape — input on the app as it is, built with the `tape` module and handed over as ZON or binary bytes — and get a ticket to ask its `outcome` (finished, or interrupted / timed out / stopped before which op); `stop` your own tape; `settled` when nothing is in flight; `snapshot` / `snapshotText` for what is on screen as ZON, a line per widget a person acts on or a tape aims at (role, name, tag, rect, parent — `replay.Snapshot`). One tape at a time, refused while a demo plays; a person's input stops it. For plugin tests, macros, tutorials | -| `"demos"` | the application (fizzy: `src/editor/Demos.zig`, over its demo player) | `list` every demo the app can play — its own, and each one a plugin registered with `host.registerDemo` (a name, a title, the tape as ZON or binary bytes, copied by the host and dropped when the plugin goes) — with each one's length, chapters and where it came from; `play` one by name, from a chapter if asked; `current` names the one playing; `generation` moves when the list does. A demo opens on a keyframe and sets the person's session aside while it plays; refused over unsaved work or while a live tape drives. For a guides browser, a plugin's own demos, an agent | +| `"demos"` | the application (fizzy: `src/editor/Demos.zig`, over its demo player) | `list` every demo the app can play — its own, and each one a plugin registered with `host.registerDemo` (a name, a title, the tape as ZON or binary bytes, copied by the host and dropped when the plugin goes) — with each one's length, chapters and where it came from; `play` one by name, from a chapter if asked; `current` names the one playing; `generation` moves when the list does. A demo opens on a keyframe and sets the person's session aside untouched while it plays, unsaved work and all; refused only while a live tape drives. For a guides browser, a plugin's own demos, an agent | Everything a plugin might reasonably ask *fizzy* for lives on `Host` instead — `openFile`, `closeDocById`, `docFromPath`, `revealPosition`, `setProjectFolder`, `isPathIgnored`, … — where it diff --git a/plugins/workbench/src/Workbench.zig b/plugins/workbench/src/Workbench.zig index f1e39cd60..29e7185d9 100644 --- a/plugins/workbench/src/Workbench.zig +++ b/plugins/workbench/src/Workbench.zig @@ -25,6 +25,9 @@ pub const Workbench = @This(); /// The split tree the panes live in. `core.widgets.DockLayout`, one panel per leaf. pub const Panes = core.widgets.DockLayout; +/// A held pane's tabs (`held_tabs`): its grouping, and the surface ids it held, owned. +pub const HeldTabs = struct { grouping: u64, ids: [][]u8 }; + allocator: std.mem.Allocator, decorators: std.ArrayListUnmanaged(BranchDecorator) = .empty, @@ -44,6 +47,11 @@ holding: bool = false, /// not be made, or before there was one), and the pane they were in. held_panes: ?Panes = null, held_open: u64 = 0, +/// The person's panes themselves while a demo has the window, and what each held (its +/// assignment, copied): set aside whole, with their documents, which stay open and are never +/// seated in the demo's panes (`workbench_layout.holdPanes`). +held_workspaces: std.AutoArrayHashMapUnmanaged(u64, Workspace) = .empty, +held_tabs: std.ArrayListUnmanaged(HeldTabs) = .empty, /// Where the panes are arranged when the app arranges them instead of `panes`: each pane a view /// of the app's, in a place of the app's, beside another by a split of the app's (fizzy's /// `Workspaces`, behind `FIZZY_WORKSPACES=1`). Null: the panes are this tree's leaves, as before. @@ -293,6 +301,35 @@ pub fn documentClosed(self: *Workbench, doc: sdk.DocHandle) void { if (active.id == doc.id) ws.active = null; } } + // One of the person's, closed while a demo has the window: gone from their held panes too. + self.forgetHeld(doc, id); +} + +/// `doc`, whose tab is `id`, from the person's held panes (`holdPanes`): its tab, where `id` is +/// given, and any pane's cached handle of it. +fn forgetHeld(self: *Workbench, doc: sdk.DocHandle, id: ?[]const u8) void { + for (self.held_workspaces.values()) |*ws| { + if (ws.active) |active| { + if (active.id == doc.id) ws.active = null; + } + } + const gone = id orelse return; + for (self.held_tabs.items) |*t| { + const k = for (t.ids, 0..) |held, k| { + if (std.mem.eql(u8, held, gone)) break k; + } else continue; + self.allocator.free(t.ids[k]); + std.mem.copyForwards([]u8, t.ids[k..], t.ids[k + 1 ..]); + t.ids = self.allocator.realloc(t.ids, t.ids.len - 1) catch t.ids[0 .. t.ids.len - 1]; + } +} + +/// Whether `doc` is one of the person's, set aside with their panes while a demo has the window +/// (`holdPanes`). +pub fn holdsDocument(self: *const Workbench, doc: sdk.DocHandle) bool { + if (!self.holding) return false; + const id = sdk.document.surfaceId(runtime.host().arena(), doc.owner.id, doc.owner.documentPath(doc)) catch return false; + return workbench_layout.heldDocument(self, doc, id); } /// The tab naming surface `id` goes from whichever pane holds it: a document that will not come @@ -309,6 +346,7 @@ pub fn documentDetached(self: *Workbench, doc: sdk.DocHandle) void { if (active.id == doc.id) ws.active = null; } } + self.forgetHeld(doc, null); } /// A document's path changed under it (an explorer rename, a Save As): its tab is keyed by @@ -318,6 +356,13 @@ pub fn documentDetached(self: *Workbench, doc: sdk.DocHandle) void { /// starts from `activeDoc()` — Save first among them — quietly does nothing. pub fn documentRenamed(self: *Workbench, doc: sdk.DocHandle, old_id: []const u8) void { const new_id = sdk.document.surfaceId(runtime.host().arena(), doc.owner.id, doc.owner.documentPath(doc)) catch return; + // One of the person's, renamed while a demo has the window: its held tab follows. + for (self.held_tabs.items) |t| for (t.ids, 0..) |held, k| { + if (!std.mem.eql(u8, held, old_id)) continue; + const copy = self.allocator.dupe(u8, new_id) catch break; + self.allocator.free(held); + t.ids[k] = copy; + }; const ws = self.swapTabId(old_id, new_id) orelse return; // Selection is by id too. Only the pane showing this document re-selects, and only // within itself — this is not a focus change, so `open_workspace_grouping` stays. diff --git a/plugins/workbench/src/workbench_layout.zig b/plugins/workbench/src/workbench_layout.zig index 5ce090f74..1ced7ff76 100644 --- a/plugins/workbench/src/workbench_layout.zig +++ b/plugins/workbench/src/workbench_layout.zig @@ -68,6 +68,9 @@ pub fn rebuildWorkspaces(wb: *Workbench) !void { } } if (held) continue; + // One of the person's, set aside with its pane while a demo has the window: it waits + // there for them, never seated in the demo's panes. + if (wb.holding and heldDocument(wb, doc, id)) continue; // The same file under another owner's id (a plugin installed since last session) would // otherwise sit beside it as a tab that never opens. const path = doc.owner.documentPath(doc); @@ -239,29 +242,74 @@ fn adoptPanes(wb: *Workbench, loaded: Panes) void { } } -/// Keep the person's arrangement while a demo has the window, and save nothing until it is put -/// back (`restorePanes`): the demo's splits are not theirs. A second hold keeps the first. +/// Set the person's panes aside while a demo has the window, and save nothing until they are put +/// back (`restorePanes`): the demo plays in a pane of its own, and its splits are not theirs. A +/// second hold keeps the first. /// -/// Nothing is saved while holding, whatever else happens, so the arrangement on disk stays the -/// person's: a copy that could not be made is read back from there instead. Before the first -/// rebuild has restored last session's panes there is nothing of theirs here to copy; that -/// restore waits for the demo to end. +/// Their panes go aside whole — the workspaces, what each holds, and the tree they sit in — and +/// their documents stay open behind them, untouched: a document of a held pane is never seated in +/// the demo's (`rebuildWorkspaces`). Nothing is saved while holding, so the arrangement on disk +/// stays theirs: a tree that could not be copied is read back from there. Before the first rebuild +/// has restored last session's panes there is no tree of theirs to copy; that restore waits for +/// the demo to end. pub fn holdPanes(wb: *Workbench) void { if (wb.holding) return; wb.holding = true; wb.held_open = wb.open_workspace_grouping; - if (!wb.restored) return; - // A copy through a snapshot: a snapshot borrows the pane names from the live tree, which the - // demo's first cut replaces (`onePane`). - const snap = wb.panes.snapshot(wb.allocator) catch |err| { - dvui.log.err("workbench: could not set the panes aside ({t}); they come back from disk", .{err}); - return; - }; - defer snap.deinit(wb.allocator); - wb.held_panes = Panes.fromSnapshot(wb.allocator, snap) catch |err| blk: { - dvui.log.err("workbench: could not set the panes aside ({t}); they come back from disk", .{err}); - break :blk null; - }; + if (wb.restored) { + // A copy through a snapshot: a snapshot borrows the pane names from the live tree, which + // the demo's own pane replaces below. + if (wb.panes.snapshot(wb.allocator)) |snap| { + defer snap.deinit(wb.allocator); + wb.held_panes = Panes.fromSnapshot(wb.allocator, snap) catch |err| blk: { + dvui.log.err("workbench: could not set the panes aside ({t}); they come back from disk", .{err}); + break :blk null; + }; + } else |err| dvui.log.err("workbench: could not set the panes aside ({t}); they come back from disk", .{err}); + } + const host = runtime.host(); + for (wb.workspaces.values()) |*ws| { + var buf: [32]u8 = undefined; + const ids = host.assignedSurfaces(Workspace.name(&buf, ws.grouping)) orelse continue; + const copy = dupeIds(wb.allocator, ids) catch |err| { + dvui.log.err("workbench: could not set pane {d}'s tabs aside ({t}); they come back from disk", .{ ws.grouping, err }); + continue; + }; + wb.held_tabs.append(wb.allocator, .{ .grouping = ws.grouping, .ids = copy }) catch |err| { + dvui.log.err("workbench: could not set pane {d}'s tabs aside ({t}); they come back from disk", .{ ws.grouping, err }); + freeIds(wb.allocator, copy); + }; + } + wb.held_workspaces = wb.workspaces; + wb.workspaces = .empty; + // The demo's pane: a grouping no pane of theirs has. + const stage = wb.newGroupingID(); + wb.workspaces.put(wb.allocator, stage, Workspace.init(stage)) catch {}; + wb.open_workspace_grouping = stage; + onePane(wb); +} + +/// Whether `doc` (its tab `id`) is one of the person's, set aside with their panes. +pub fn heldDocument(wb: *const Workbench, doc: sdk.DocHandle, id: []const u8) bool { + if (wb.held_workspaces.contains(doc.owner.documentGrouping(doc))) return true; + for (wb.held_tabs.items) |t| for (t.ids) |held| if (std.mem.eql(u8, held, id)) return true; + return false; +} + +fn dupeIds(gpa: std.mem.Allocator, ids: []const []const u8) ![][]u8 { + const out = try gpa.alloc([]u8, ids.len); + var n: usize = 0; + errdefer freeIds(gpa, out[0..n]); + for (ids, out) |id, *o| { + o.* = try gpa.dupe(u8, id); + n += 1; + } + return out; +} + +fn freeIds(gpa: std.mem.Allocator, ids: [][]u8) void { + for (ids) |id| gpa.free(id); + gpa.free(ids); } /// One pane, as a demo's keyframe declares the window: the open one, the others closing with @@ -279,12 +327,30 @@ pub fn onePane(wb: *Workbench) void { adoptPanes(wb, one); } -/// The person's arrangement again (`holdPanes`), and saving with it: the copy, or what is on disk -/// where there is none. Before last session's panes were ever restored, one pane, for the next -/// rebuild to restore them into. +/// The person's panes again (`holdPanes`), and saving with them: the demo's go, theirs come back +/// holding what they held, in their tree — the copy, or what is on disk where there is none. +/// Before last session's panes were ever restored, one pane, for the next rebuild to restore +/// them into. pub fn restorePanes(wb: *Workbench) void { if (!wb.holding) return; wb.holding = false; + const host = runtime.host(); + for (wb.workspaces.values()) |*ws| { + var buf: [32]u8 = undefined; + host.assignSurfaces(Workspace.name(&buf, ws.grouping), null) catch {}; + ws.deinit(); + } + wb.workspaces.deinit(wb.allocator); + wb.workspaces = wb.held_workspaces; + wb.held_workspaces = .empty; + for (wb.held_tabs.items) |t| { + var buf: [32]u8 = undefined; + const ids: []const []const u8 = t.ids; + host.assignSurfaces(Workspace.name(&buf, t.grouping), ids) catch |err| + dvui.log.err("workbench: pane {d}'s tabs did not come back ({t})", .{ t.grouping, err }); + freeIds(wb.allocator, t.ids); + } + wb.held_tabs.clearAndFree(wb.allocator); if (wb.held_panes) |held| { wb.held_panes = null; adoptPanes(wb, held); diff --git a/sdk/src/services/demos.zig b/sdk/src/services/demos.zig index 5bb52d027..87b846e8e 100644 --- a/sdk/src/services/demos.zig +++ b/sdk/src/services/demos.zig @@ -50,7 +50,9 @@ pub const Api = struct { unknown, /// Something else is driving the app (a live tape): try again once it is done. busy, - /// The person has unsaved work open, which a demo would set aside: they are told so. + /// Never answered: a demo plays on a stage of its own, the person's work, saved or not, + /// set aside untouched. Kept so a plugin switching on it still builds; it goes at the + /// next SDK release that moves the boundary for another reason. unsaved, }; diff --git a/src/editor/Demo.zig b/src/editor/Demo.zig index 34ac30e50..68c241b17 100644 --- a/src/editor/Demo.zig +++ b/src/editor/Demo.zig @@ -1,7 +1,7 @@ //! fizzy's stage for demo automation (`app.automation`), and how a demo gets started. //! //! The player delivers a tape's input; this decides what fizzy's state *is* to a demo. A keyframe -//! closes every document without saving, mounts the keyframe's files in memory at its root +//! closes every document of the demo's without saving, mounts the keyframe's files in memory at its root //! (`demo://`, a `core.vfs.Mem`, so nothing touches disk and every replay starts from the //! same bytes), makes that the project folder on the explorer's Files view, resets the layout, and //! opens the files the keyframe names. Seeking back is that, then a replay. @@ -10,15 +10,18 @@ //! explorer put away — and a demo opens the explorer from the rail when it is about to use it and //! puts it away after (`catalog.openFile`), as a person short of room would. //! -//! 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: 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. +//! A demo plays inside the person's own copy of fizzy, on a stage of its own, so it costs them +//! nothing and waits for nothing: `begin` sets their session aside — their panes, which the +//! workbench holds whole (`Workbench.holdPanes`), the documents in them, which stay open behind +//! them untouched (hidden, never closed, saved, reloaded or put back by a snapshot), the project +//! folder, the explorer's open folders and the keyboard focus — and flushes any pending layout and +//! settings writes. Nothing is persisted while the demo is loaded (`active`). The demo's documents +//! are only its own: its mounted files (`demo://…`) and any it makes. `end` closes those, reloads +//! the layout from `layout.zon`, and gives the person back their panes, their documents as they +//! left them (unsaved edits and history with them), their folder and their focus. //! //! **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, +//! files as saved, each of the demo's open documents' state from its owner (`captureDocumentState` — contents, //! caret, scroll), which is active, the explorer open or shut and its open folders, every plugin's //! settings, and keyboard focus. Restoring one puts them back in place — documents opened since //! are closed, the rest restored where they sit — so a seek back within a scene costs no reload. @@ -30,7 +33,6 @@ const Demo = @This(); const std = @import("std"); -const builtin = @import("builtin"); const dvui = @import("dvui"); const fizzy = @import("../fizzy.zig"); const core = fizzy.core; @@ -102,19 +104,16 @@ const Pending = union(enum) { const Saved = struct { folder: ?[]u8, - /// Open documents, the active one last so it is focused again. - docs: []Doc, - - const Doc = struct { - path: []u8, - /// The pane it was in (`documentGrouping`): it opens there again, in the arrangement put - /// back (`Workbench.restorePanes`), rather than every document in the one pane. - grouping: u64, - /// 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, - }; + /// The person's open documents, by id: set aside, open all along. Every other document is the + /// demo's (`ownsDocument`). + docs: []const u64, + /// The one they were in. + active: ?u64, + /// The explorer's open folders, and whether its root had opened (`Workbench.file_tree_root_opened`). + branches: []const dvui.Id, + root_opened: ?usize, + focus: dvui.Id, + focus_subwindow: dvui.Id, }; /// Inert until `attach`: nothing loaded, so nothing in `tick` acts on it. @@ -256,8 +255,8 @@ fn queue(self: *Demo, p: Pending) void { self.pending = p; } -/// A demo replaces the open documents, so it waits for unsaved work to be saved or closed. A -/// demo's own documents are not anyone's work: one demo may replace another. +/// A demo plays on a stage of its own (`begin`), so the person's work, saved or not, never stops +/// one: only another tape driving the app does. fn mayStart(self: *Demo) bool { return self.startable() == .started; } @@ -268,12 +267,6 @@ pub fn startable(self: *Demo) sdk.services.demos.Api.Play { core.dialogs.toast(@src(), .{ .message = "Something is driving fizzy right now — the demo can start once it has finished." }); return .busy; } - for (self.editor.app.open_files.values()) |doc| { - if (!doc.owner.isDirty(doc)) continue; - if (std.mem.startsWith(u8, doc.owner.documentPath(doc), "demo://")) continue; - core.dialogs.toast(@src(), .{ .message = "Save or close your changes first — a demo replaces the open files while it plays." }); - return .unsaved; - } return .started; } @@ -474,35 +467,56 @@ fn begin(ctx: *anyopaque, _: *const automation.Tape) void { editor.flushLayout(); editor.flushSettings(); self.saveSettings(); - // The document panes too: kept in memory, and none of the demo's saved over them. + // The one they are in, before their panes go: the demo's has none. + const in_front = if (editor.activeDoc()) |d| d.id else null; + // Their panes go aside whole, the demo playing in one of its own. editor.workbench.holdPanes(); + // Their documents stay open behind them, hidden: no place shows them while the demo has the + // window, and nothing of the demo's closes, saves or restores them (`ownsDocument`). A demo's + // left open by the last (one whose close failed) is not theirs. const gpa = editor.app.gpa; - var docs: std.ArrayList(Saved.Doc) = .empty; - const active_id = if (editor.activeDoc()) |d| d.id else null; - var active_doc: ?Saved.Doc = null; + var docs: std.ArrayList(u64) = .empty; 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; - // 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, .grouping = doc.owner.documentGrouping(doc) }; - 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 (std.mem.startsWith(u8, doc.owner.documentPath(doc), "demo://")) continue; + docs.append(gpa, doc.id) catch continue; } - if (active_doc) |d| docs.append(gpa, d) catch freeSavedDoc(gpa, d); + const held = docs.toOwnedSlice(gpa) catch &.{}; + const branches: []const dvui.Id = if (gpa.alloc(dvui.Id, editor.workbench.open_branches.count())) |out| blk: { + var it = editor.workbench.open_branches.keyIterator(); + for (out) |*b| b.* = it.next().?.*; + break :blk out; + } else |_| &.{}; // 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. const folder = editor.app.folder orelse ""; self.saved = .{ .folder = if (folder.len > 0 and !std.mem.startsWith(u8, folder, "demo://")) gpa.dupe(u8, folder) catch null else null, - .docs = docs.toOwnedSlice(gpa) catch &.{}, + .docs = held, + .active = in_front, + .branches = branches, + .root_opened = editor.workbench.file_tree_root_opened, + .focus = dvui.focusedWidgetId() orelse .zero, + .focus_subwindow = dvui.focusedSubwindowId(), }; + for (held) |id| self.hideDocument(id, true); +} + +/// Whether `doc` is the demo's: one it opened or made, not one of the person's — open at `begin`, +/// or landing since in a pane of theirs (an open on its way as the demo started, a document their +/// plugin's reload brought back), which the workbench holds (`Workbench.holdsDocument`). +pub fn ownsDocument(self: *const Demo, doc: sdk.DocHandle) bool { + const saved = self.saved orelse return true; + if (std.mem.indexOfScalar(u64, saved.docs, doc.id) != null) return false; + return !self.editor.workbench.holdsDocument(doc); +} + +/// Hide the person's document `id` from every place while the demo has the window, or show it. +/// One whose surface is made again meanwhile (a rename) is hidden by `Editor.registerDocSurface`. +fn hideDocument(self: *Demo, id: u64, hidden: bool) void { + const ds = self.editor.doc_surfaces.get(id) orelse return; + self.editor.app.host.setSurfaceHidden(ds.id, hidden); } fn end(ctx: *anyopaque) void { @@ -511,46 +525,40 @@ fn end(ctx: *anyopaque) void { const gpa = editor.app.gpa; self.fast = false; if (self.quitting) { + for (editor.app.open_files.keys()) |id| self.hideDocument(id, false); self.freeSaved(); self.freeSavedSettings(); editor.workbench.restorePanes(); return; } self.restoreSettings(); - self.closeAllDocuments(); + self.closeDemoDocuments(); self.unmountFiles(); - // Before the person's documents reopen: into the pane they were in. - editor.workbench.restorePanes(); // The layout as the user left it: `begin` flushed it, and nothing has been written since. editor.app.layout.resetLayout(gpa); editor.loadSavedLayout(); editor.app.layout.dirty = false; editor.command_palette.finishClose(); + // Their panes, each holding what it held: after the layout's reload, which would otherwise + // put back only what the file had of them. + editor.workbench.restorePanes(); 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, d.grouping) catch |err| - dvui.log.warn("demo: could not reopen {s}: {t}", .{ d.path, err }); - } + // What is open now is all theirs: the demo's have closed. + for (editor.app.open_files.keys()) |id| self.hideDocument(id, false); 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) |d| { - if (d.bytes != null) continue; - _ = editor.openFilePath(d.path, d.grouping) 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); + editor.workbench.open_branches.clearRetainingCapacity(); + for (saved.branches) |id| editor.workbench.setBranchOpen(id, true); + editor.workbench.file_tree_root_opened = saved.root_opened; + if (saved.active) |id| { + if (editor.app.open_files.getIndex(id)) |index| editor.workbench.setActiveDocIndex(index); } + if (saved.focus != .zero) dvui.focusWidget(saved.focus, saved.focus_subwindow, null); self.freeSaved(); dvui.refresh(null, @src(), null); } @@ -559,26 +567,11 @@ 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| freeSavedDoc(gpa, d); gpa.free(saved.docs); + gpa.free(saved.branches); 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); @@ -586,7 +579,7 @@ fn keyframe(ctx: *anyopaque, kf: *const automation.Tape.Keyframe) void { const gpa = editor.app.gpa; editor.command_palette.finishClose(); - self.closeAllDocuments(); + self.closeDemoDocuments(); // One pane: a split a later moment made is gone at a cut, as on the first pass. editor.workbench.onePane(); self.applySettings(kf.settings); @@ -761,16 +754,22 @@ fn unmountFiles(self: *Demo) void { } } -/// Close every document, discarding changes — they are the demo's, or `mayStart` refused. -/// Loads still on their way are dropped too, or they would land in the next keyframe. -fn closeAllDocuments(self: *Demo) void { +/// Close every document of the demo's, discarding changes: none is the person's, whose are set +/// aside (`ownsDocument`). Loads still on their way are dropped too, or they would land in the +/// next keyframe. +fn closeDemoDocuments(self: *Demo) void { const editor = self.editor; editor.cancelAllLoadingJobs(); while (editor.doc_io.loads.count() > 0) editor.doc_io.cancel(editor.doc_io.loads.keys()[0]); while (editor.openings.entries.count() > 0) editor.openings.drop(editor, editor.openings.entries.keys()[0]); - while (editor.app.open_files.count() > 0) { - const id = editor.app.open_files.keys()[editor.app.open_files.count() - 1]; - editor.rawCloseFileID(id) catch break; + var i = editor.app.open_files.count(); + while (i > 0) { + i -= 1; + // A close can take others with it (an archive's, its mount's files). + if (i >= editor.app.open_files.count()) continue; + const doc = editor.app.open_files.values()[i]; + if (!self.ownsDocument(doc)) continue; + editor.rawCloseFileID(doc.id) catch |err| dvui.log.warn("demo: could not close a document of its own: {t}", .{err}); } } @@ -895,6 +894,7 @@ fn fill(self: *Demo, snap: *Snapshot, root: []const u8, mem: *core.vfs.Mem) !boo const active_id = if (editor.activeDoc()) |d| d.id else null; var docs: std.ArrayList(Snapshot.Doc) = .empty; for (editor.app.open_files.values()) |doc| { + if (!self.ownsDocument(doc)) continue; const state = doc.owner.captureDocumentState(doc, a) orelse return false; if (active_id == doc.id) snap.active = docs.items.len; try docs.append(a, .{ @@ -944,6 +944,7 @@ fn restore(ctx: *anyopaque, raw: *anyopaque) bool { while (i > 0) { i -= 1; const doc = editor.app.open_files.values()[i]; + if (!self.ownsDocument(doc)) continue; const path = doc.owner.documentPath(doc); const kept = for (snap.docs) |d| { if (std.mem.eql(u8, d.path, path)) break true; @@ -988,7 +989,7 @@ fn release(ctx: *anyopaque, raw: *anyopaque) void { from(ctx).editor.app.gpa.destroy(snap); } -/// The model a snapshot holds, less what may differ between passes — scroll, focus, and which +/// The model a snapshot holds (the demo's own documents, never the person's), less what may differ between passes — scroll, focus, and which /// folders the explorer shows open, whose root opens on its own when its listing lands: /// documents' fingerprints from their owners, which is active, the explorer shut or not, and the /// files as saved. @@ -997,6 +998,7 @@ fn fingerprint(ctx: *anyopaque) u64 { const editor = self.editor; var h = std.hash.Wyhash.init(0xde40); for (editor.app.open_files.values()) |doc| { + if (!self.ownsDocument(doc)) continue; h.update(doc.owner.documentPath(doc)); const print: u64 = doc.owner.documentFingerprint(doc) orelse 0; h.update(std.mem.asBytes(&print)); diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 9b5a5cbee..9212add9e 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -2975,6 +2975,9 @@ fn registerDocSurface(editor: *Editor, doc: sdk.DocHandle) !void { }); // The owner's own name for it, when it has one, copied in (`Host.documentTitleChanged`). editor.app.host.documentTitleChanged(doc); + // One of the person's, set aside while a demo plays (made again by a rename, or landing in a + // pane of theirs): hidden too. + if (!editor.demo.ownsDocument(doc)) editor.app.host.setSurfaceHidden(ds.id, true); } fn unregisterDocSurface(editor: *Editor, doc_id: u64) void { @@ -5323,9 +5326,7 @@ fn fizzyAssignedSurfaces(ctx: *anyopaque, region: []const u8) ?[]const []const u fn fizzySelectInRegion(ctx: *anyopaque, region: []const u8, id: []const u8) void { const editor = fizzyCtx(ctx); - // Either list: this frame's if the shape has declared it already, else last frame's. A - // region that exists in neither has not been drawn yet, and its first draw selects the - // first thing it shows — which for a pane just created around one document is that one. + // Either list: this frame's if the shape has declared it already, else last frame's. for (editor.app.layout.regions_building.items) |r| if (std.mem.eql(u8, r.name, region)) { editor.app.host.setSelectionForKey(r.selectionKey(), id); return; @@ -5334,6 +5335,11 @@ fn fizzySelectInRegion(ctx: *anyopaque, region: []const u8, id: []const u8) void editor.app.host.setSelectionForKey(r.selectionKey(), id); return; }; + // Not drawn yet — a pane just made, which a place is named for — so kept under that name, for + // its first draw to show. Dropped, the first draw showed the first thing the pane held: a + // demo's pane, new when it starts, came up on its first document there and on its last after + // a seek back. + editor.app.host.setSelectionForKey(Region.namedSelectionKey(region), id); } fn fizzyRevealSurface(ctx: *anyopaque, id: []const u8) bool { diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index db3f1b6a7..7ff0dc67e 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -253,8 +253,8 @@ fn cmdDemoPlayNamed(state: *anyopaque, args: DemoByName.Args, call: *sdk.Command switch (api.play(args.name, args.at)) { .started => {}, .unknown => return call.fail("no demo named '{s}'", .{args.name}), - .busy => return call.fail("something else is driving fizzy: a demo can start once it has finished", .{}), - .unsaved => return call.fail("there is unsaved work open, which a demo would set aside", .{}), + // `unsaved` is never answered (`Demo.startable`). + .busy, .unsaved => return call.fail("something else is driving fizzy: a demo can start once it has finished", .{}), } } diff --git a/tests/demos.zig b/tests/demos.zig index 28f30358a..0ef026bac 100644 --- a/tests/demos.zig +++ b/tests/demos.zig @@ -271,6 +271,79 @@ test "demo: every bundled demo plays to the end through fizzy's stage, a seek la try dvui.testing.settle(headlessFrame); } + // A demo plays on a stage of its own: unsaved work of the person's never stops one, and is + // there after it exactly as it was — still open, still unsaved, its history and its pane + // theirs — through a play, a seek back and an end, with settings.zon never written. + { + const wb = &app.editor.workbench; + const draft = try std.fmt.allocPrint(gpa, "{s}/draft.md", .{app.root_buf[0 .. std.mem.indexOfScalar(u8, &app.root_buf, 0) orelse 0]}); + defer gpa.free(draft); + try std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = draft, .data = "# Draft\n" }); + _ = try app.editor.openFilePath(draft, wb.currentGroupingID()); + frames = 0; + while (host.docFromPath(draft) == null and frames < 200) : (frames += 1) _ = try dvui.testing.step(headlessFrame); + try dvui.testing.settle(headlessFrame); + const doc = host.docFromPath(draft) orelse return error.TestUnexpectedResult; + const write = try std.fmt.allocPrint(gpa, ".{{ .path = \"{s}\", .text = \"# Draft, not saved\\n\" }}", .{draft}); + defer gpa.free(write); + try std.testing.expect(host.callCommand("text.write", write, host.arena()) == .ok); + try dvui.testing.settle(headlessFrame); + try std.testing.expect(doc.owner.isDirty(doc)); + const line_before = doc.owner.historyLine(doc, host.arena()) orelse return error.TestUnexpectedResult; + const steps_before = line_before.entries.len; + const at_before = line_before.at; + const grouping = doc.owner.documentGrouping(doc); + const panes_before = wb.workspaces.count(); + const docs_open = app.editor.app.open_files.count(); + try std.testing.expectEqual(doc.id, app.editor.activeDoc().?.id); + app.editor.flushSettings(); + const settings_path = try std.fs.path.join(gpa, &.{ app.editor.app.config_folder, "settings.zon" }); + defer gpa.free(settings_path); + const settings_before = try std.Io.Dir.cwd().statFile(dvui.io, settings_path, .{}); + + try app.editor.demo.playTape(glassy); + try std.testing.expect(app.editor.demo.active()); + frames = 0; + while (p.state != .ended and frames < 2_000) : (frames += 1) _ = try dvui.testing.step(headlessFrame); + try std.testing.expectEqual(automation.Player.State.ended, p.state); + // On the stage, only the demo's: its pane, its document in front, the draft nowhere. + try std.testing.expectEqual(@as(usize, 1), wb.workspaces.count()); + try std.testing.expect(std.mem.startsWith(u8, activePath(app.editor), "demo://")); + try std.testing.expect(!wb.workspaces.contains(grouping)); + p.seek(0); + frames = 0; + while (p.state == .seeking and frames < 400) : (frames += 1) _ = try dvui.testing.step(headlessFrame); + p.seek(@floatFromInt(p.duration())); + frames = 0; + while (p.state == .seeking and frames < 400) : (frames += 1) _ = try dvui.testing.step(headlessFrame); + p.unload(); + try dvui.testing.settle(headlessFrame); + + // The same document, never closed: unsaved, its text and history as they were, in its pane + // and in front. + const back = host.docFromPath(draft) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(doc.id, back.id); + try std.testing.expect(back.owner.isDirty(back)); + const read_args = try std.fmt.allocPrint(gpa, ".{{ .path = \"{s}\" }}", .{draft}); + defer gpa.free(read_args); + const out = host.callCommand("text.read", read_args, host.arena()).ok orelse return error.TestUnexpectedResult; + try std.testing.expect(std.mem.indexOf(u8, out, "# Draft, not saved") != null); + const line_after = back.owner.historyLine(back, host.arena()) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(steps_before, line_after.entries.len); + try std.testing.expectEqual(at_before, line_after.at); + try std.testing.expectEqual(grouping, back.owner.documentGrouping(back)); + try std.testing.expectEqual(panes_before, wb.workspaces.count()); + try std.testing.expectEqual(docs_open, app.editor.app.open_files.count()); + try std.testing.expectEqual(doc.id, app.editor.activeDoc().?.id); + try std.testing.expect(wb.held_tabs.items.len == 0 and wb.held_workspaces.count() == 0); + // Not written while the demo played, nor by its end. + const settings_after = try std.Io.Dir.cwd().statFile(dvui.io, settings_path, .{}); + try std.testing.expectEqual(settings_before.mtime, settings_after.mtime); + try std.testing.expectEqual(settings_before.size, settings_after.size); + try app.editor.rawCloseFileID(back.id); + try dvui.testing.settle(headlessFrame); + } + // A tape loaded some other way is not the one the service started. try app.editor.demo.play("markdown"); try std.testing.expectEqual(@as(?[]const u8, null), api.current());