diff --git a/app/AGENTS.md b/app/AGENTS.md index 535c3dd90..14e9a6e8f 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -33,3 +33,12 @@ each directory is. springs, easing or trails driven by the pointer: merged Liquid Glass necks re-form visibly under tremor. A one-shot, time-driven curve started by a discrete event (the aimed bubble changing) is fine. +- **std offers AF_UNIX on Windows only to a target whose minimum is 10 1803** (`win10_rs4`, + `std.Io.net.has_unix_sockets`); below it, listening fails with `AddressFamilyUnsupported` + whatever the machine runs. Fizzy's `build.zig` raises the minimum for the control server + (`control/`); an app built on fizzy with its own target must too, or its server does not start. + On Windows the socket is a reparse point `statFile` cannot open, so its inode is not checked. +- **End a blocked `accept` by cancelling its `Io` task, never by closing the listener.** std's + Windows `accept` treats the cancelled wait that closing causes as `unreachable` (a panic), and + connecting to the socket's path to wake it reaches whoever listens there now. `Future.cancel` + interrupts it on every OS (`control/Server.zig`). diff --git a/app/ActivityFeed.zig b/app/ActivityFeed.zig index 657196536..c70ae3d57 100644 --- a/app/ActivityFeed.zig +++ b/app/ActivityFeed.zig @@ -44,7 +44,9 @@ pub fn clear(self: *ActivityFeed) void { self.len = 0; } -fn push(self: *ActivityFeed, title: []const u8, now: i128) void { +/// Show `title` as though a command of that name had just run: how the app says something +/// about the document itself (a share kept, a tape stopped) in the same glass as its commands. +pub fn push(self: *ActivityFeed, title: []const u8, now: i128) void { if (self.len == max) { std.mem.copyForwards(Item, self.items[0 .. max - 1], self.items[1..max]); self.len -= 1; diff --git a/app/App.zig b/app/App.zig index d1de8c64a..1339e2beb 100644 --- a/app/App.zig +++ b/app/App.zig @@ -44,6 +44,7 @@ const Layout = @import("layout/Layout.zig"); const Host = sdk.Host; const PluginLoader = @import("root.zig").store.Loader; const SettingsWatcher = @import("watch/SettingsWatcher.zig"); +const ControlServer = @import("control/Server.zig"); const ExecutableWatcher = @import("watch/ExecutableWatcher.zig"); const FolderWatcher = @import("watch/FolderWatcher.zig"); const Keymap = @import("keymap/Keymap.zig"); @@ -260,6 +261,11 @@ settings_save_deadline_ns: i128 = 0, /// `SettingsWatcher.start`'s doc comment), torn down first in `deinit`. settings_watcher: ?SettingsWatcher = null, +/// The control server (`app.control`), while it runs: started in `postInit` when `FIZZY_CONTROL=1` +/// (plans/CONTROL_SERVER_PLAN.md, steps 1–3 land dark), answered each frame, stopped at quit. +/// Always void on wasm, which has no socket. +control: if (builtin.target.cpu.arch == .wasm32) void else ?ControlServer = if (builtin.target.cpu.arch == .wasm32) {} else null, + /// Watches the app's own executable while `settings.restart_on_rebuild` is on: a rebuild restarts /// the app into it, keeping every document. Null while the setting is off, and wherever /// `SettingsWatcher` would be. diff --git a/app/HistoryTransport.zig b/app/HistoryTransport.zig index f00163b02..3b471d71a 100644 --- a/app/HistoryTransport.zig +++ b/app/HistoryTransport.zig @@ -62,26 +62,41 @@ fn stopId(line: sdk.Plugin.HistoryLine, i: usize) u64 { return if (i == 0) 0 else line.entries[i - 1].id; } +pub const DrawOptions = struct { + /// A share button at the bar's end: the app has a way to share how the document was made + /// (fizzy: its track as a tape). What a press does is the app's (`Pressed.share`). + share: bool = false, +}; + +/// What was pressed on the bar this frame that is the app's to act on. +pub const Pressed = struct { + share: bool = false, +}; + /// Draw the bar over `area` (the document's pane, physical) when it shows, and take its input. /// `id_extra` keeps one pane's bar apart from another's. -pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physical, id_extra: usize) void { +pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physical, id_extra: usize, opts: DrawOptions) Pressed { + var pressed: Pressed = .{}; const now = dvui.frameTimeNS(); // The pointer on it, or a drag in it, keeps it. if (self.drag != null) self.shown.stir(now) else if (self.place(area, now)) |b| if (b.contains(dvui.currentWindow().mouse_pt)) self.shown.stir(now); const p = self.shown.openness(now); - if (p <= 0) return; + if (p <= 0) return pressed; const arena = dvui.currentWindow().arena(); - const line = doc.owner.historyLine(doc, arena) orelse return; + const line = doc.owner.historyLine(doc, arena) orelse return pressed; const n = line.entries.len; const wrs = dvui.windowRectScale(); const s = wrs.s; - const whole = geometry(area.toNatural()) orelse return; + const whole = geometry(area.toNatural()) orelse return pressed; const w = whole.w; const center = whole.center(); + // Share, when there is a way to: the last bubble, inside the bar's width. + const end_w: f32 = if (opts.share) button_d + gap else 0; const back: dvui.Rect = .{ .x = whole.x, .y = center.y - button_d / 2, .w = button_d, .h = button_d }; - const fwd: dvui.Rect = .{ .x = whole.x + w - button_d, .y = back.y, .w = button_d, .h = button_d }; - const track: dvui.Rect = .{ .x = back.x + button_d + gap, .y = whole.y, .w = w - 2 * (button_d + gap), .h = bar_h }; + const fwd: dvui.Rect = .{ .x = whole.x + w - button_d - end_w, .y = back.y, .w = button_d, .h = button_d }; + const share: dvui.Rect = .{ .x = whole.x + w - button_d, .y = back.y, .w = if (opts.share) button_d else 0, .h = button_d }; + const track: dvui.Rect = .{ .x = back.x + button_d + gap, .y = whole.y, .w = w - 2 * (button_d + gap) - end_w, .h = bar_h }; const hit = wrs.rectToPhysical(whole.outsetAll(4)); var float: dvui.FloatingWidget = undefined; @@ -93,7 +108,7 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica // and went), it holds nothing, and the knob is the document's again. if (self.drag != null and !dvui.captured(fid)) self.drag = null; - const phys = [3]dvui.Rect.Physical{ wrs.rectToPhysical(back), wrs.rectToPhysical(track), wrs.rectToPhysical(fwd) }; + const phys = [4]dvui.Rect.Physical{ wrs.rectToPhysical(back), wrs.rectToPhysical(track), wrs.rectToPhysical(fwd), wrs.rectToPhysical(share) }; const pad = 14; const x0 = phys[1].x + pad * s; const x1 = phys[1].x + phys[1].w - pad * s; @@ -106,6 +121,7 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica core.anchor.markRect(fid, phys[0], true, "history.back:{s}", .{path}); core.anchor.markRect(fid, .{ .x = x0, .y = phys[1].y, .w = x1 - x0, .h = phys[1].h }, true, "history.line:{s}", .{path}); core.anchor.markRect(fid, phys[2], true, "history.forward:{s}", .{path}); + if (opts.share) core.anchor.markRect(fid, phys[3], true, "history.share:{s}", .{path}); // Input: the buttons step, the line goes where it is pressed and follows a drag. var go: ?usize = null; @@ -122,6 +138,8 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica go = line.at -| 1; } else if (phys[2].contains(me.p)) { go = @min(n, line.at + 1); + } else if (opts.share and phys[3].contains(me.p)) { + pressed.share = true; } else if (line_hit.contains(me.p)) { dvui.captureMouse(float.data(), e.num); self.drag = rules.stopAt(me.p.x, x0, x1, n); @@ -149,16 +167,22 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica const prev_alpha = dvui.alpha(p); defer dvui.alphaSet(prev_alpha); const pt = dvui.currentWindow().mouse_pt; - var bubbles: [3]core.dialogs.Bubble = undefined; + var bubbles: [4]core.dialogs.Bubble = undefined; for (phys, &bubbles, 0..) |r, *b, i| b.* = .{ .rect = r, - .light = if (i != 1 and r.contains(pt) and (if (i == 0) at > 0 else at < n)) core.dialogs.bubble_hover else 0, + .light = if (r.contains(pt) and switch (i) { + 0 => at > 0, + 2 => at < n, + 3 => true, + else => false, + }) core.dialogs.bubble_hover else 0, }; - core.dialogs.glassBubbles(fid, &bubbles, s, .{ .merge = 14, .shadow = 1 }); + core.dialogs.glassBubbles(fid, bubbles[0..if (opts.share) 4 else 3], s, .{ .merge = 14, .shadow = 1 }); const ink = dvui.themeGet().color(.window, .text); button(bounds, back, "history_back", icons.tvg.lucide.undo, ink.opacity(if (at > 0) 0.85 else 0.3)); button(bounds, fwd, "history_forward", icons.tvg.lucide.redo, ink.opacity(if (at < n) 0.85 else 0.3)); + if (opts.share) button(bounds, share, "history_share", icons.tvg.lucide.@"share-2", ink.opacity(0.85)); // What the document is at, over the line: the step's title, and how far along. const font = dvui.Font.theme(.body).larger(-2); @@ -178,6 +202,7 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica // own (it starts to go, it is gone). if (p < 1) dvui.refresh(null, @src(), fid); if (self.shown.nextChange(now)) |t| dvui.timer(fid, @intCast(@min(@divTrunc(t - now, 1000) + 1, std.math.maxInt(i32)))); + return pressed; } /// A step button's icon, in the middle of its bubble `r` (window-natural; `bounds` the layer's). diff --git a/app/Tracks.zig b/app/Tracks.zig index 836462eb2..8272eaa14 100644 --- a/app/Tracks.zig +++ b/app/Tracks.zig @@ -28,6 +28,7 @@ const dvui = @import("dvui"); const core = @import("core"); const sdk = @import("fizzy_sdk"); const state_delta = @import("state_delta.zig"); +const tape = @import("tape"); gpa: std.mem.Allocator, /// The frame clock: what a step's moment is read from. A bench gives its own. @@ -83,6 +84,9 @@ pub const Track = struct { format: u32, /// The document when the track began; null until the next frame takes it. base: ?State = null, + /// Where the document's history stood when the base was taken (`HistoryLine`'s step id, 0 + /// before any): what an export rewinds the document to for its file (`exportTape`). + base_top: u64 = 0, steps: std.ArrayListUnmanaged(Rec) = .empty, /// Every step's effect and motion, one after another. bytes: std.ArrayListUnmanaged(u8) = .empty, @@ -240,6 +244,7 @@ pub fn tick(self: *Tracks) void { t.steps.clearRetainingCapacity(); t.bytes.clearRetainingCapacity(); t.base = self.take(t.doc, 0) orelse continue; + t.base_top = historyTop(t.doc, self.gpa) orelse 0; } else if (t.checkpoint_due) { var s = self.take(t.doc, t.steps.items.len) orelse continue; // As what differs from the latest whole state, unless that is half the document or @@ -329,6 +334,14 @@ pub fn shed(self: *Tracks) void { } } +/// The step `doc`'s history is at (0 before any), or null when its owner keeps no line. +fn historyTop(doc: sdk.DocHandle, gpa: std.mem.Allocator) ?u64 { + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + const line = doc.owner.historyLine(doc, arena.allocator()) orelse return null; + return if (line.at == 0) 0 else line.entries[line.at - 1].id; +} + fn take(self: *Tracks, doc: sdk.DocHandle, count: usize) ?Track.State { const bytes = doc.owner.captureDocumentState(doc, self.gpa) orelse return null; return .{ .at = self.now(), .step = count, .bytes = bytes, .fingerprint = doc.owner.documentFingerprint(doc) }; @@ -425,6 +438,217 @@ pub fn replayInto(self: *Tracks, doc_id: u64, target: sdk.DocHandle, count: usiz }; } +pub const ExportOptions = struct { + /// Keep every pause as long as it was. + real_time: bool = false, + /// The range of the track's steps to share: from the document as it was after the first + /// `from`, through step `to` (exclusive; null: the last). + from: usize = 0, + to: ?usize = null, +}; +/// A pause longer than this plays as `short_gap_ms`. +pub const gap_ms = 2000; +pub const short_gap_ms = 600; + +pub const ExportError = error{ + /// No open document has a track by that id: a tape is made from the document itself. + NoTrack, + /// The track has nothing yet: its base is taken at the next frame. + NoBase, + /// The owner keeps no history to go back through. + NoHistory, + /// Where the range begins is no longer on the document's history (undone past and edited + /// over), was reached by undoing past the track's start, or a step joined the entry it began + /// in: the document cannot be put back there. + StartNotInHistory, + /// The range holds no steps. + EmptyRange, + /// The owner could not write the document as a file. + NoBytes, + /// The document went back to the track's start for its file and could not come forward + /// again: it is left there, the rest of its history a redo away. + NotReturned, + OutOfMemory, +}; + +/// Where `t`'s document's history stood after its first `count` steps: the top entry's id (0 for +/// none), worked out from the steps themselves, so it holds for any owner. Null where it cannot be +/// known: an undo or redo reached an entry from before the track began. +pub fn topAfter(t: *const Track, gpa: std.mem.Allocator, count: usize) error{OutOfMemory}!?u64 { + // The entries added since the track began, and how many of them are done. + var line: std.ArrayListUnmanaged(u64) = .empty; + defer line.deinit(gpa); + var at: usize = 0; + for (t.steps.items[0..@min(count, t.steps.items.len)]) |r| switch (r.kind) { + .added => { + line.shrinkRetainingCapacity(at); + try line.append(gpa, r.id); + at += 1; + }, + .joined => {}, + .undone => at = std.mem.indexOfScalar(u64, line.items[0..at], r.id) orelse return null, + .redone => at = 1 + (std.mem.indexOfScalar(u64, line.items, r.id) orelse return null), + .restarted => return null, + }; + return if (at == 0) t.base_top else line.items[at - 1]; +} + +/// What a range of a track holds, for the person to see before it is shared (`summarize`). +pub const Summary = struct { + /// The range, as it will be exported (`to` resolved and clamped). + from: usize, + to: usize, + steps: usize, + /// From the range's first step to its last's end, as it happened; and as the tape plays it, + /// its long pauses shortened unless `real_time`. + recorded_ms: u64, + played_ms: u64, + /// Who made its steps, each once, in the order they first came. + authors: []const sdk.Author, + /// Why the range cannot be shared as it stands, when it cannot (`exportTape` would refuse). + refused: ?ExportError = null, +}; + +/// The range `opts` names of `doc`'s track, summed up: how many steps, how long, by whom, and +/// whether the document can be put back where it begins. Moves nothing; `arena` holds the authors. +pub fn summarize(self: *Tracks, host: *const sdk.Host, doc: sdk.DocHandle, arena: std.mem.Allocator, opts: ExportOptions) ExportError!Summary { + const t = self.open.get(doc.id) orelse return error.NoTrack; + const base = t.base orelse return error.NoBase; + const to = @min(opts.to orelse t.steps.items.len, t.steps.items.len); + const first = @min(opts.from, to); + var out: Summary = .{ .from = first, .to = to, .steps = to - first, .recorded_ms = 0, .played_ms = 0, .authors = &.{} }; + if (first == to) { + out.refused = error.EmptyRange; + return out; + } + _ = self.startOf(doc, arena, t, first) catch |err| { + out.refused = err; + }; + + var timing: Timing = .init(t, base, first, opts.real_time); + var authors: std.ArrayListUnmanaged(sdk.Author) = .empty; + for (t.steps.items[first..to]) |r| { + out.played_ms = timing.place(r) + r.ms; + const who = host.stepAuthor(doc.id, r.id); + for (authors.items) |a| { + if (a.same(who)) break; + } else try authors.append(arena, who); + } + out.recorded_ms = timing.recorded_end -| (if (first == 0) base.at else t.steps.items[first].at); + out.authors = authors.items; + return out; +} + +/// The history entry a range starting after step `first` goes back to, checked against the +/// document's line as it is now. Errors as `exportTape` refuses. +fn startOf(self: *Tracks, doc: sdk.DocHandle, arena: std.mem.Allocator, t: *const Track, first: usize) ExportError!u64 { + _ = self; + const top = (try topAfter(t, arena, first)) orelse return error.StartNotInHistory; + // A step after it that joined that entry changed it: going back to the entry would keep it. + if (top != 0) for (t.steps.items[first..]) |r| if (r.kind == .joined and r.id == top) return error.StartNotInHistory; + const line = doc.owner.historyLine(doc, arena) orelse return error.NoHistory; + // Gone from the line (undone past, and edited over). + if (top != 0) for (line.entries) |e| { + if (e.id == top) break; + } else return error.StartNotInHistory; + return top; +} + +/// When each step plays, in ms from the tape's start: each pause runs from where the step before +/// it ended, recorded and played, so a stroke drawn over a second and a half is never cut short +/// by the next step arriving early; a pause past `gap_ms` plays as `short_gap_ms` unless real time. +const Timing = struct { + played_end: u32 = 0, + recorded_end: u32, + real_time: bool, + + /// From the track's start, its time opening counts; from a step, the range opens on it. + fn init(t: *const Track, base: Track.State, first: usize, real_time: bool) Timing { + return .{ .recorded_end = if (first == 0) base.at else t.steps.items[first].at, .real_time = real_time }; + } + + fn place(self: *Timing, r: Track.Rec) u32 { + const gap = r.at -| self.recorded_end; + const at = self.played_end + if (!self.real_time and gap > gap_ms) short_gap_ms else gap; + self.played_end = at + r.ms; + self.recorded_end = @max(self.recorded_end, r.at + r.ms); + return at; + } +}; + +/// `doc`'s track as a tape, in the binary form, deflated (`tape.binary`): a keyframe holding +/// the document's file as it was where the range begins (`ExportOptions.from`; the track's start +/// by default), a wait for it to open, then each step of the range at the moment it came, a +/// replay of how the document was made that plays anywhere. +/// +/// The file is the document's own, written by its owner (`Plugin.documentBytes`), as it stood at +/// the range's start (`topAfter`): the document is taken back there through its history (`historyGoTo`), +/// written, and brought forward again in the same call, with nothing drawn between and neither +/// the track nor the activity feed hearing of it. The person's history is as it was. Named +/// `name` (the document's file name, with its owner's extension when it has none), under +/// `demo://share`. Owned by `gpa`. +/// +/// Pauses longer than `ExportOptions.gap_ms` play as `short_gap_ms` (`real_time` keeps them as +/// they were): a tape of an hour's drawing is the drawing, not the hour, and the moment before +/// the first step is a beat rather than however long the document sat open. +pub fn exportTape(self: *Tracks, host: *sdk.Host, doc: sdk.DocHandle, gpa: std.mem.Allocator, opts: ExportOptions) ExportError![]u8 { + const prof = core.profile.begin("fizzy", "tracks.export"); + defer prof.end(); + const t = self.open.get(doc.id) orelse return error.NoTrack; + const base = t.base orelse return error.NoBase; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const to = @min(opts.to orelse t.steps.items.len, t.steps.items.len); + const first = @min(opts.from, to); + if (first == to) return error.EmptyRange; + // Refused before anything moves. + const start_top = try self.startOf(doc, arena, t, first); + const line = doc.owner.historyLine(doc, arena) orelse return error.NoHistory; + const now_top = if (line.at == 0) 0 else line.entries[line.at - 1].id; + + const name = blk: { + const base_name = std.fs.path.basename(doc.owner.documentPath(doc)); + if (std.fs.path.extension(base_name).len > 0) break :blk base_name; + const types = doc.owner.fileTypes(); + if (types.len == 0) break :blk base_name; + break :blk try std.mem.concat(arena, u8, &.{ base_name, types[0] }); + }; + + // Back to where the range begins, the file written there, and forward again: that last + // move is checked, since a failure would leave the person's document at the range's start. + const bytes = bytes: { + const was_track_muted = self.muted; + self.muted = doc.id; + defer self.muted = was_track_muted; + const was_muted = host.activity_muted; + host.activity_muted = true; + defer host.activity_muted = was_muted; + const moved = now_top != start_top; + if (moved and !doc.owner.historyGoTo(doc, start_top)) return error.StartNotInHistory; + const written = doc.owner.documentBytes(doc, name, arena) catch null; + if (moved and !doc.owner.historyGoTo(doc, now_top)) { + std.log.err("tracks: {s} went back to its track's start and could not come forward; redo brings the rest back", .{doc.owner.documentPath(doc)}); + return error.NotReturned; + } + break :bytes written orelse return error.NoBytes; + }; + + const files = try arena.alloc(tape.Tape.File, 1); + files[0] = .{ .path = name, .text = bytes, .owner = doc.owner.id, .format = t.format }; + const keyframes = try arena.alloc(tape.Tape.Keyframe, 1); + keyframes[0] = .{ .root = "demo://share", .files = files, .open = try arena.dupe([]const u8, &.{name}), .layout = .focused }; + const ops = try arena.alloc(tape.Tape.Op, to - first + 2); + ops[0] = .{ .at = 0, .do = .{ .keyframe = 0 } }; + ops[1] = .{ .at = 0, .do = .{ .wait = .{ .until = .idle } } }; + var timing: Timing = .init(t, base, first, opts.real_time); + for (t.steps.items[first..to], ops[2..]) |r, *op| { + op.* = .{ .at = timing.place(r), .ms = r.ms, .do = .{ .step = .{ .file = name, .effect = t.effect(r), .motion = t.motion(r) } } }; + } + const title = try std.fmt.allocPrint(arena, "How {s} was made", .{name}); + return tape.binary.encode(gpa, .{ .name = "share", .title = title, .ops = ops, .keyframes = keyframes }, .{ .deflate = true }) catch return error.OutOfMemory; +} + /// The open document's track, or else the latest that ended for it. pub fn trackOf(self: *Tracks, doc_id: u64) ?*Track { if (self.open.get(doc_id)) |t| return t; @@ -601,3 +825,17 @@ test "over the cap, ended tracks go first, then all but each open track's newest try testing.expectEqual(track.steps.items.len, track.checkpoints.items[0].step); try testing.expectEqual(t.held, track.size()); } + +test "where the history stood after each step, from the steps alone" { + var t: Track = .{ .doc_id = 1, .doc = Stand.doc(1), .owner = &Stand.plugin, .format = 1, .base_top = 7 }; + defer t.steps.deinit(testing.allocator); + const steps = [_]struct { sdk.Step.Kind, u64 }{ + .{ .added, 10 }, .{ .joined, 10 }, .{ .added, 11 }, .{ .undone, 11 }, .{ .undone, 10 }, + .{ .redone, 10 }, .{ .added, 12 }, .{ .undone, 7 }, + }; + for (steps) |st| try t.steps.append(testing.allocator, .{ .kind = st[0], .id = st[1], .at = 0, .ms = 0, .effect = .{ .start = 0, .len = 0 }, .motion = .{ .start = 0, .len = 0 } }); + // Before any step, where the track began; then each step's top. Adding 12 after undoing 11 + // overwrites it, and undoing 7 (from before the track) cannot be known. + const want = [_]?u64{ 7, 10, 10, 11, 10, 7, 10, 12, null }; + for (want, 0..) |w, count| try testing.expectEqual(w, try topAfter(&t, testing.allocator, count)); +} diff --git a/app/TrimBar.zig b/app/TrimBar.zig new file mode 100644 index 000000000..99b91322d --- /dev/null +++ b/app/TrimBar.zig @@ -0,0 +1,191 @@ +//! A trim of a document's history, as macOS trims a recording: the bar at the bottom of its pane +//! holds the history as a line with a yellow bracket over the part kept, and the document shows +//! wherever a handle is dragged, so the person sees where the part they keep begins and ends rather +//! than choosing a time. Cancel and share either side, play to see the part kept. +//! +//! What a trim keeps, and how a press takes a handle, are `history_transport.Trim`'s rules. The +//! bar seeks the document itself while a handle or the playhead is dragged (one `historyGoTo` a +//! frame at most, as the transport does); what cancel, play and share do is the app's (`Pressed`). +//! Shown for as long as the app draws it: a trim is something the person is in the middle of. +const TrimBar = @This(); + +const std = @import("std"); +const dvui = @import("dvui"); +const core = @import("core"); +const sdk = @import("fizzy_sdk"); +const icons = @import("icons"); +const rules = @import("history_transport.zig"); + +/// What the pointer holds while it drags on the line, until the drag ends. +grip: ?rules.Trim.Grip = null, + +const bar_h: f32 = 48; +const button_d: f32 = 34; +const gap: f32 = 10; +const max_w: f32 = 620; +/// macOS's trim yellow. +const yellow: dvui.Color = .{ .r = 0xF5, .g = 0xC0, .b = 0x2E }; + +/// What was pressed this frame, for the app to act on. +pub const Pressed = struct { + cancel: bool = false, + play: bool = false, + share: bool = false, + /// The playhead or a handle was taken: a playing preview stops. + grabbed: bool = false, +}; + +/// The id `historyGoTo` takes for stop `i`: 0 before the first step, else the step's. +fn stopId(line: sdk.Plugin.HistoryLine, i: usize) u64 { + return if (i == 0) 0 else line.entries[i - 1].id; +} + +/// Draw the bar over `area` (the document's pane, physical) and take its input. `trim` is the +/// part kept, over `doc`'s history; `summary` says what it holds, over the line; `playing` turns +/// play into pause. `id_extra` keeps one pane's bar apart from another's. +pub fn draw(self: *TrimBar, doc: sdk.DocHandle, area: dvui.Rect.Physical, id_extra: usize, trim: *rules.Trim, summary: []const u8, playing: bool) Pressed { + var pressed: Pressed = .{}; + const arena = dvui.currentWindow().arena(); + const line = doc.owner.historyLine(doc, arena) orelse return pressed; + const n = line.entries.len; + if (n != trim.n) return pressed; + + const wrs = dvui.windowRectScale(); + const s = wrs.s; + const nat = area.toNatural(); + const w = @min(max_w, nat.w - 24); + if (w < 6 * button_d) return pressed; + const whole: dvui.Rect = .{ .x = nat.x + (nat.w - w) / 2, .y = nat.y + nat.h - 14 - bar_h, .w = w, .h = bar_h }; + const cy = whole.y + bar_h / 2; + const cancel: dvui.Rect = .{ .x = whole.x, .y = cy - button_d / 2, .w = button_d, .h = button_d }; + const play: dvui.Rect = .{ .x = cancel.x + button_d + gap, .y = cancel.y, .w = button_d, .h = button_d }; + const share: dvui.Rect = .{ .x = whole.x + w - button_d, .y = cancel.y, .w = button_d, .h = button_d }; + const track: dvui.Rect = .{ .x = play.x + button_d + gap, .y = whole.y, .w = share.x - gap - (play.x + button_d + gap), .h = bar_h }; + + var float: dvui.FloatingWidget = undefined; + float.init(@src(), .{}, .{ .rect = whole.outsetAll(16), .id_extra = id_extra }); + defer float.deinit(); + const bounds = float.data().rect; + const fid = float.data().id; + if (self.grip != null and !dvui.captured(fid)) self.grip = null; + + const phys = [4]dvui.Rect.Physical{ wrs.rectToPhysical(cancel), wrs.rectToPhysical(play), wrs.rectToPhysical(track), wrs.rectToPhysical(share) }; + const pad = 16; + const x0 = phys[2].x + pad * s; + const x1 = phys[2].x + phys[2].w - pad * s; + const line_r: dvui.Rect.Physical = .{ .x = x0, .y = phys[2].y + phys[2].h - 24 * s, .w = @max(1, x1 - x0), .h = 16 * s }; + const line_hit = line_r.outsetAll(8 * s); + const path = doc.owner.documentPath(doc); + core.anchor.markRect(fid, phys[0], true, "trim.cancel:{s}", .{path}); + core.anchor.markRect(fid, phys[1], true, "trim.play:{s}", .{path}); + core.anchor.markRect(fid, .{ .x = x0, .y = line_r.y, .w = x1 - x0, .h = line_r.h }, true, "trim.line:{s}", .{path}); + core.anchor.markRect(fid, phys[3], true, "trim.share:{s}", .{path}); + + var go: ?usize = null; + const hit = wrs.rectToPhysical(whole.outsetAll(4)); + for (dvui.events()) |*e| { + if (!dvui.eventMatch(e, .{ .id = fid, .r = hit })) continue; + const me = switch (e.evt) { + .mouse => |m| m, + else => continue, + }; + switch (me.action) { + .press => if (me.button.pointer()) { + e.handle(@src(), float.data()); + if (phys[0].contains(me.p)) { + pressed.cancel = true; + } else if (phys[1].contains(me.p)) { + pressed.play = true; + } else if (phys[3].contains(me.p)) { + pressed.share = true; + } else if (line_hit.contains(me.p)) { + dvui.captureMouse(float.data(), e.num); + const grip = trim.grab(me.p.x, x0, x1, 10 * s); + self.grip = grip; + go = trim.drag(grip, rules.stopAt(me.p.x, x0, x1, n)); + pressed.grabbed = true; + } + }, + .motion => if (self.grip) |grip| if (dvui.captured(fid)) { + e.handle(@src(), float.data()); + go = trim.drag(grip, rules.stopAt(me.p.x, x0, x1, n)); + }, + .release => if (me.button.pointer() and dvui.captured(fid)) { + e.handle(@src(), float.data()); + dvui.captureMouse(null, e.num); + self.grip = null; + }, + else => {}, + } + } + var at = line.at; + if (go) |to| if (to != at) { + if (doc.owner.historyGoTo(doc, stopId(line, to))) at = to; + dvui.refresh(null, @src(), fid); + }; + + const pt = dvui.currentWindow().mouse_pt; + var bubbles: [4]core.dialogs.Bubble = undefined; + for (phys, &bubbles, 0..) |r, *b, i| b.* = .{ .rect = r, .light = if (i != 2 and r.contains(pt)) core.dialogs.bubble_hover else 0 }; + core.dialogs.glassBubbles(fid, &bubbles, s, .{ .merge = 14, .shadow = 1 }); + + const ink = dvui.themeGet().color(.window, .text); + button(bounds, cancel, "trim_cancel", icons.tvg.lucide.x, ink.opacity(0.85)); + button(bounds, play, "trim_play", if (playing) icons.tvg.lucide.pause else icons.tvg.lucide.play, ink.opacity(0.85)); + button(bounds, share, "trim_share", icons.tvg.lucide.check, yellow); + + const font = dvui.Font.theme(.body).larger(-2); + dvui.labelNoFmt(@src(), summary, .{}, .{ + .rect = .{ .x = track.x + pad - bounds.x, .y = track.y + 5 - bounds.y, .w = track.w - 2 * pad, .h = font.lineHeight() }, + .padding = .{}, + .margin = .{}, + .font = font, + .color_text = .{ .color = ink.opacity(0.85) }, + }); + paintLine(line_r, trim.*, at, ink, s); + return pressed; +} + +/// A button's icon, in the middle of its bubble `r` (window-natural; `bounds` the layer's). +fn button(bounds: dvui.Rect, r: dvui.Rect, name: []const u8, tvg: []const u8, color: dvui.Color) void { + const d = r.w * 0.44; + core.icon.icon(@src(), name, tvg, .{ + .stroke_color = .{ .color = color }, + .fill_color = .{ .color = color }, + }, .{ + .id_extra = @intFromPtr(name.ptr), + .rect = .{ .x = r.x + (r.w - d) / 2 - bounds.x, .y = r.y + (r.h - d) / 2 - bounds.y, .w = d, .h = d }, + }); +} + +/// The line `r` (physical): the whole history faint, the part kept brighter inside the yellow +/// bracket, its two handles, and the playhead at `at`. +fn paintLine(r: dvui.Rect.Physical, trim: rules.Trim, at: usize, ink: dvui.Color, s: f32) void { + const h = 6 * s; + const mid = r.y + r.h / 2; + const whole: dvui.Rect.Physical = .{ .x = r.x, .y = mid - h / 2, .w = r.w, .h = h }; + whole.fill(.all(h / 2), .{ .color = .{ .color = ink.opacity(0.12) } }); + + const xf = rules.stopX(trim.from, r.x, r.x + r.w, trim.n); + const xt = rules.stopX(trim.to, r.x, r.x + r.w, trim.n); + const kept: dvui.Rect.Physical = .{ .x = xf, .y = whole.y, .w = @max(1, xt - xf), .h = h }; + kept.fill(.all(h / 2), .{ .color = .{ .color = ink.opacity(0.4) } }); + + // The bracket: yellow over and under the part kept, a handle at each end. + const handle_w = 7 * s; + const bracket: dvui.Rect.Physical = .{ .x = xf - handle_w, .y = r.y, .w = xt - xf + 2 * handle_w, .h = r.h }; + const edge = 2 * s; + (dvui.Rect.Physical{ .x = xf, .y = bracket.y, .w = xt - xf, .h = edge }).fill(.{}, .{ .color = .{ .color = yellow } }); + (dvui.Rect.Physical{ .x = xf, .y = bracket.y + bracket.h - edge, .w = xt - xf, .h = edge }).fill(.{}, .{ .color = .{ .color = yellow } }); + for ([_]f32{ xf - handle_w, xt }) |x| { + const hr: dvui.Rect.Physical = .{ .x = x, .y = bracket.y, .w = handle_w, .h = bracket.h }; + hr.fill(.all(3 * s), .{ .color = .{ .color = yellow } }); + const notch: dvui.Rect.Physical = .{ .x = x + handle_w / 2 - 0.75 * s, .y = mid - 4 * s, .w = 1.5 * s, .h = 8 * s }; + notch.fill(.all(0.75 * s), .{ .color = .{ .color = .black } }); + } + + // The playhead: where the document is. + const xa = rules.stopX(at, r.x, r.x + r.w, trim.n); + const head: dvui.Rect.Physical = .{ .x = xa - 1 * s, .y = r.y - 3 * s, .w = 2 * s, .h = r.h + 6 * s }; + head.fill(.all(1 * s), .{ .color = .{ .color = .white } }); +} diff --git a/app/control/Server.zig b/app/control/Server.zig new file mode 100644 index 000000000..8bcea3d51 --- /dev/null +++ b/app/control/Server.zig @@ -0,0 +1,465 @@ +//! The control server's transport: HTTP/1.1 on a Unix socket only this user can open, served by +//! threads of its own (plans/CONTROL_SERVER_PLAN.md). Nothing here touches the app: a request is +//! handed to the UI thread (`take`, once a frame), and the connection's thread waits until it is +//! answered (`Request.answer`), then writes the response. +//! +//! Each connection has a thread of its own, so a client holding a connection open never makes +//! another wait, and keep-alive works as curl and every HTTP library expect. The UI thread answers +//! requests in the order they arrived. Idle, the server costs a thread blocked in `accept`. +//! +//! Lifted from the agent plugin's socket server (`fizzyedit/agent` `src/Server.zig`), which kept a +//! private ZON-lines wire behind a token; here the socket's permissions are the authorization. +//! +//! std-only, with a unit-test root of its own (`build/app.zig`). +const Server = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; + +/// The most a request body may hold. A command's arguments can be a whole drawing, but not this. +pub const max_body = 64 * 1024 * 1024; + +gpa: std.mem.Allocator, +io: Io, +socket_path: []const u8, +/// Wakes the app so its next frame drains the queue. Safe from any thread. +wake: *const fn () void, +/// The accept loop (`run`), an `Io` task so `stop` can cancel its blocked `accept`: std interrupts +/// it on every OS (a signal on POSIX, a cancelled wait on Windows). Closing the listener under it +/// instead is `unreachable` in std's Windows `accept`, and connecting to the path to wake it +/// reaches whoever listens there now, which after a restart's handover is the next fizzy. +accept_task: ?Io.Future(void) = null, +/// Set by `start` before the accept task exists, so the task and `stop` only ever read it. +listener: ?Io.net.Server = null, +/// The connections being served, under `lock`, so `stop` can end their reads and wait for them. +conns: std.ArrayListUnmanaged(*Conn) = .empty, +stopping: std.atomic.Value(bool) = .init(false), +/// The socket file this server made, by inode: `stop` removes the path only while it is still +/// this one, not the socket a fizzy taking over has made there since. +socket_inode: ?Io.File.INode = null, +/// Requests waiting for the UI thread, under `lock`. +lock: Io.Mutex = .init, +pending: std.ArrayListUnmanaged(*Request) = .empty, + +const Conn = struct { + stream: Io.net.Stream, + thread: ?std.Thread = null, + done: std.atomic.Value(bool) = .init(false), + /// A request of this connection's is queued or being answered, under `lock`: `stop` leaves its + /// socket open so the answer ("fizzy is closing") can still be written. + waiting: bool = false, +}; + +/// One request on its way through the UI thread. Everything in it lives in the connection's +/// arena until it is answered. +pub const Request = struct { + method: std.http.Method, + /// The path and query, as sent: `/commands/text.insert?x=1`. + target: []const u8, + content_type: ?[]const u8, + body: []const u8, + /// Set by `answer`. `body` is `gpa`-owned and the connection's thread frees it. + response: Response = .{}, + done: Io.Event = .unset, + + /// The path without its query. + pub fn path(self: *const Request) []const u8 { + return self.target[0 .. std.mem.indexOfScalar(u8, self.target, '?') orelse self.target.len]; + } + + /// Answer, from the UI thread. `body` is copied with the server's `gpa`. + pub fn answer(self: *Request, server: *Server, status: std.http.Status, content_type: []const u8, body: []const u8) void { + self.response = .{ + .status = status, + .content_type = content_type, + .body = server.gpa.dupe(u8, body) catch null, + }; + if (self.response.body == null) self.response.status = .internal_server_error; + self.done.set(server.io); + } +}; + +pub const Response = struct { + status: std.http.Status = .internal_server_error, + /// Static: a string literal, never freed. + content_type: []const u8 = "text/plain", + body: ?[]u8 = null, +}; + +/// Listen, then serve on a thread of its own. Everything that can fail happens here, on the +/// caller's thread, so a server that returns is one that listens. +pub fn start(self: *Server) !void { + // Windows has AF_UNIX from 10 1803, and std offers it only to a target whose minimum is that + // (`std.Io.net.has_unix_sockets`); fizzy's build sets it. + if (!Io.net.has_unix_sockets) return error.UnixSocketsUnsupported; + const addr = try Io.net.UnixAddress.init(self.socket_path); + // A socket left by a fizzy that did not quit cleanly, or one a fizzy this one takes over from + // still holds: this run's now. The other leaves it alone at its stop (`socket_inode`). + Io.Dir.deleteFileAbsolute(self.io, self.socket_path) catch {}; + self.listener = try addr.listen(self.io, .{}); + errdefer { + self.listener.?.deinit(self.io); + self.listener = null; + } + if (comptime builtin.target.os.tag != .windows) { + // This user only. The umask usually says so already; a shared machine's may not. + try Io.Dir.cwd().setFilePermissions(self.io, self.socket_path, .fromMode(0o600), .{}); + // Not on Windows, where the socket is a reparse point `statFile` cannot open: there this + // server leaves its socket behind at `stop`, and the next one deletes it (above). + if (Io.Dir.cwd().statFile(self.io, self.socket_path, .{})) |st| self.socket_inode = st.inode else |_| {} + } + self.accept_task = try self.io.concurrent(run, .{self}); +} + +/// Requests the UI thread should answer now. On the UI thread, each frame; each must be answered. +pub fn take(self: *Server, out: *std.ArrayListUnmanaged(*Request)) void { + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + out.appendSlice(self.gpa, self.pending.items) catch return; + self.pending.clearRetainingCapacity(); +} + +pub fn stop(self: *Server) void { + self.stopping.store(true, .release); + { + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + // Whatever the UI thread will never answer now, answered: its connection's thread is + // waiting, and writes it before it sees `stopping` and ends. + for (self.pending.items) |r| r.answer(self, .service_unavailable, "text/plain", "fizzy is closing\n"); + self.pending.clearRetainingCapacity(); + // The rest are reading their next request: end those reads. Only the ones still open, + // since shutting down a closed descriptor is a panic in std. + for (self.conns.items) |c| if (!c.done.load(.acquire) and !c.waiting) c.stream.shutdown(self.io, .both) catch {}; + } + if (self.accept_task) |*t| t.cancel(self.io); + self.accept_task = null; + // No more are accepted: wait for each connection's thread, its read ended above. + for (self.conns.items) |c| { + if (c.thread) |t| t.join(); + self.gpa.destroy(c); + } + self.conns.deinit(self.gpa); + if (self.listener) |*l| l.deinit(self.io); + self.listener = null; + if (self.socket_inode) |ours| { + if (Io.Dir.cwd().statFile(self.io, self.socket_path, .{})) |st| { + if (st.inode == ours) Io.Dir.deleteFileAbsolute(self.io, self.socket_path) catch {}; + } else |_| {} + } + self.pending.deinit(self.gpa); +} + +fn run(self: *Server) void { + var listener = self.listener.?; + while (!self.stopping.load(.acquire)) { + const stream = listener.accept(self.io) catch |err| switch (err) { + error.Canceled => return, + // Out of descriptors, say: let it pass rather than spin. Cancelable, like `accept`. + else => { + self.io.sleep(.fromMilliseconds(50), .awake) catch return; + continue; + }, + }; + if (self.stopping.load(.acquire)) { + stream.close(self.io); + return; + } + const conn = self.gpa.create(Conn) catch { + stream.close(self.io); + continue; + }; + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + conn.* = .{ .stream = stream }; + self.reap(); + self.conns.append(self.gpa, conn) catch { + stream.close(self.io); + self.gpa.destroy(conn); + continue; + }; + conn.thread = std.Thread.spawn(.{}, serveConn, .{ self, conn }) catch { + _ = self.conns.pop(); + stream.close(self.io); + self.gpa.destroy(conn); + continue; + }; + } +} + +fn serveConn(self: *Server, conn: *Conn) void { + self.serve(conn); + // Closed under `lock`, with `done`, so `stop` never shuts down a descriptor already closed. + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + conn.stream.close(self.io); + conn.done.store(true, .release); +} + +/// Join and free connections whose clients have gone. Under `lock`. +fn reap(self: *Server) void { + var i: usize = 0; + while (i < self.conns.items.len) { + const c = self.conns.items[i]; + if (!c.done.load(.acquire)) { + i += 1; + continue; + } + if (c.thread) |t| t.join(); + self.gpa.destroy(c); + _ = self.conns.swapRemove(i); + } +} + +/// Answer without reading the body, then close: nothing after it on the stream can be trusted to +/// be the next request. +fn refuse(head: *std.http.Server.Request, status: std.http.Status, message: []const u8) void { + // Answered without a `100 Continue` first: the client is not told to send what is refused. + head.head.expect = null; + head.respond(message, .{ .status = status, .keep_alive = false }) catch {}; +} + +fn serve(self: *Server, conn: *Conn) void { + var read_buf: [64 * 1024]u8 = undefined; + var write_buf: [16 * 1024]u8 = undefined; + var reader = conn.stream.reader(self.io, &read_buf); + var writer = conn.stream.writer(self.io, &write_buf); + var http: std.http.Server = .init(&reader.interface, &writer.interface); + while (!self.stopping.load(.acquire)) { + var arena_state: std.heap.ArenaAllocator = .init(self.gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var head = http.receiveHead() catch return; + if (head.head.expect) |e| if (!std.ascii.eqlIgnoreCase(e, "100-continue")) + return refuse(&head, .expectation_failed, "only Expect: 100-continue is understood\n"); + if (head.head.content_length) |n| if (n > max_body) + return refuse(&head, .payload_too_large, "request body too large\n"); + // std reads no body for GET and HEAD, so one sent would be taken for the next request. + const sends_body = (head.head.content_length orelse 0) > 0 or head.head.transfer_encoding != .none; + if (sends_body and !head.head.method.requestHasBody()) + return refuse(&head, .bad_request, "a GET or HEAD takes no body\n"); + // Copied before the body is read: reading it invalidates the head's strings. + var req: Request = .{ + .method = head.head.method, + .target = arena.dupe(u8, head.head.target) catch return, + .content_type = if (head.head.content_type) |t| arena.dupe(u8, t) catch return else null, + .body = "", + }; + var body_buf: [4096]u8 = undefined; + const body_reader = head.readerExpectContinue(&body_buf) catch return; + req.body = body_reader.allocRemaining(arena, .limited(max_body)) catch |err| switch (err) { + error.StreamTooLong => return refuse(&head, .payload_too_large, "request body too large\n"), + else => return, + }; + { + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + // `stop` answers what is queued before it joins: nothing may be queued after. + if (self.stopping.load(.acquire)) return; + self.pending.append(self.gpa, &req) catch return; + conn.waiting = true; + } + self.wake(); + // Uncancelable: `&req` is queued, and an early return would leave the UI thread writing + // into a frame that is gone. + req.done.waitUncancelable(self.io); + const body = req.response.body orelse "out of memory\n"; + defer if (req.response.body) |b| self.gpa.free(b); + const sent = head.respond(body, .{ + .status = req.response.status, + .extra_headers = &.{.{ .name = "content-type", .value = req.response.content_type }}, + }); + { + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + conn.waiting = false; + } + sent catch return; + if (!head.head.keep_alive) return; + } +} + +// ---- tests --------------------------------------------------------------------------------- + +fn noWake() void {} + +const TestClient = struct { + io: Io, + path: []const u8, + request: []const u8, + reply: [8192]u8 = undefined, + reply_len: usize = 0, + + fn run(self: *TestClient) void { + const addr = Io.net.UnixAddress.init(self.path) catch return; + // `start` has returned, so the server listens. + const stream = addr.connect(self.io) catch return; + defer stream.close(self.io); + var wbuf: [1024]u8 = undefined; + var w = stream.writer(self.io, &wbuf); + w.interface.writeAll(self.request) catch return; + w.interface.flush() catch return; + var rbuf: [1024]u8 = undefined; + var r = stream.reader(self.io, &rbuf); + self.reply_len = r.interface.readSliceShort(&self.reply) catch 0; + } +}; + +test "a request reaches the UI thread and its answer goes back" { + if (!Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir = try tmp.dir.realPathFileAlloc(io, ".", gpa); + defer gpa.free(dir); + const path = try std.fs.path.join(gpa, &.{ dir, "c.sock" }); + defer gpa.free(path); + + var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; + try server.start(); + defer server.stop(); + + var client: TestClient = .{ + .io = io, + .path = path, + .request = "POST /commands/test.echo?x=1 HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\n" ++ + "Content-Length: 11\r\nConnection: close\r\n\r\n{\"line\":12}", + }; + const thread = try std.Thread.spawn(.{}, TestClient.run, .{&client}); + + // The UI thread's side: take what came, answer it. + var taken: std.ArrayListUnmanaged(*Request) = .empty; + defer taken.deinit(gpa); + var waited: usize = 0; + while (taken.items.len == 0 and waited < 2000) : (waited += 1) { + server.take(&taken); + if (taken.items.len == 0) io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + try std.testing.expectEqual(@as(usize, 1), taken.items.len); + const req = taken.items[0]; + try std.testing.expectEqual(std.http.Method.POST, req.method); + try std.testing.expectEqualStrings("/commands/test.echo", req.path()); + try std.testing.expectEqualStrings("application/json", req.content_type.?); + try std.testing.expectEqualStrings("{\"line\":12}", req.body); + req.answer(&server, .ok, "application/json", "{\"ok\":true}"); + thread.join(); + + const reply = client.reply[0..client.reply_len]; + try std.testing.expect(std.mem.startsWith(u8, reply, "HTTP/1.1 200 OK\r\n")); + try std.testing.expect(std.mem.indexOf(u8, reply, "content-type: application/json\r\n") != null); + try std.testing.expect(std.mem.endsWith(u8, reply, "\r\n\r\n{\"ok\":true}")); + + // Only this user may open it. + if (builtin.target.os.tag == .windows) return; + const st = try Io.Dir.cwd().statFile(io, path, .{}); + try std.testing.expectEqual(@as(std.posix.mode_t, 0o600), st.permissions.toMode() & 0o777); +} + +fn testSocket(gpa: std.mem.Allocator, tmp: *std.testing.TmpDir) ![]u8 { + const dir = try tmp.dir.realPathFileAlloc(std.testing.io, ".", gpa); + defer gpa.free(dir); + return std.fs.path.join(gpa, &.{ dir, "c.sock" }); +} + +test "a request still queued at quit is answered: fizzy is closing" { + if (!Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try testSocket(gpa, &tmp); + defer gpa.free(path); + + var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; + try server.start(); + var client: TestClient = .{ .io = io, .path = path, .request = "POST /commands/x HTTP/1.1\r\nHost: x\r\nContent-Length: 0\r\n\r\n" }; + const thread = try std.Thread.spawn(.{}, TestClient.run, .{&client}); + // Queued, and never taken: the app is quitting. + var waited: usize = 0; + while (waited < 2000) : (waited += 1) { + server.lock.lockUncancelable(io); + const n = server.pending.items.len; + server.lock.unlock(io); + if (n == 1) break; + io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + server.stop(); + thread.join(); + const reply = client.reply[0..client.reply_len]; + try std.testing.expect(std.mem.startsWith(u8, reply, "HTTP/1.1 503 Service Unavailable\r\n")); + try std.testing.expect(std.mem.endsWith(u8, reply, "fizzy is closing\n")); +} + +test "what cannot be served is refused before its body, and never reaches the app" { + if (!Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try testSocket(gpa, &tmp); + defer gpa.free(path); + + var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; + try server.start(); + defer server.stop(); + const cases = [_]struct { request: []const u8, status: []const u8 }{ + // Too large: refused without a `100 Continue` that would invite the bytes. + .{ .request = "POST /x HTTP/1.1\r\nHost: x\r\nExpect: 100-continue\r\nContent-Length: 999999999\r\n\r\n", .status = "HTTP/1.1 413 " }, + .{ .request = "POST /x HTTP/1.1\r\nHost: x\r\nExpect: something-else\r\nContent-Length: 2\r\n\r\n{}", .status = "HTTP/1.1 417 " }, + // A body on a GET would otherwise be read as the next request. + .{ .request = "GET / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhello", .status = "HTTP/1.1 400 " }, + }; + for (cases) |case| { + var client: TestClient = .{ .io = io, .path = path, .request = case.request }; + client.run(); + const reply = client.reply[0..client.reply_len]; + try std.testing.expect(std.mem.startsWith(u8, reply, case.status)); + try std.testing.expect(std.mem.indexOf(u8, reply, "100 Continue") == null); + } + var taken: std.ArrayListUnmanaged(*Request) = .empty; + defer taken.deinit(gpa); + server.take(&taken); + try std.testing.expectEqual(@as(usize, 0), taken.items.len); +} + +test "a socket path the OS cannot hold fails at start, not later" { + if (!Io.net.has_unix_sockets) return error.SkipZigTest; + const long = "/" ++ @as([Io.net.UnixAddress.max_len + 8]u8, @splat('d')); + var server: Server = .{ .gpa = std.testing.allocator, .io = std.testing.io, .socket_path = long, .wake = noWake }; + try std.testing.expectError(error.NameTooLong, server.start()); +} + +test "a server that took over the path keeps it when the old one stops" { + if (!Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try testSocket(gpa, &tmp); + defer gpa.free(path); + + // A restart's handover: the new fizzy listens on the profile's socket while the old runs. + var old: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; + try old.start(); + var new: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; + try new.start(); + defer new.stop(); + // The old one stops without waking, or deleting, the new one's socket. + old.stop(); + + var client: TestClient = .{ .io = io, .path = path, .request = "GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" }; + const thread = try std.Thread.spawn(.{}, TestClient.run, .{&client}); + var taken: std.ArrayListUnmanaged(*Request) = .empty; + defer taken.deinit(gpa); + var waited: usize = 0; + while (taken.items.len == 0 and waited < 2000) : (waited += 1) { + new.take(&taken); + if (taken.items.len == 0) io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + try std.testing.expectEqual(@as(usize, 1), taken.items.len); + taken.items[0].answer(&new, .ok, "text/plain", "new"); + thread.join(); + try std.testing.expect(std.mem.endsWith(u8, client.reply[0..client.reply_len], "\r\n\r\nnew")); +} diff --git a/app/control/json.zig b/app/control/json.zig new file mode 100644 index 000000000..86cd8746d --- /dev/null +++ b/app/control/json.zig @@ -0,0 +1,186 @@ +//! JSON at the control server's edge, ZON inside: a command's arguments arrive as JSON and go to +//! `Host.callCommand` as ZON, and its ZON result goes back out as JSON. The one place the two +//! meet, so the plugin boundary never sees JSON (plans/CONTROL_SERVER_PLAN.md). +//! +//! The mapping is the obvious one both ways — objects and structs, arrays and tuples, strings, +//! numbers, booleans, null — with one thing JSON cannot say: an enum. Going in, a string is an +//! enum literal where the command's parameter is an enumeration (`enum_keys`); everywhere else it +//! is a string. Going out, an enum literal is its name as a string. A caller who needs an enum +//! deeper than a top-level argument sends ZON instead (`Content-Type: application/zon`). +//! +//! std-only, with a unit-test root of its own (`build/app.zig`). +const std = @import("std"); + +pub const Error = error{ OutOfMemory, NotJson, NotAnObject, NotZon, NotUtf8 }; + +/// `json`, an object of arguments, as a ZON struct literal: `{"line": 12}` → `.{.line=12}`. A +/// top-level string whose key is in `enum_keys` becomes an enum literal. An empty body is no +/// arguments, `""`. +pub fn zonFromJson(arena: std.mem.Allocator, json: []const u8, enum_keys: []const []const u8) Error![]u8 { + if (std.mem.trim(u8, json, " \t\r\n").len == 0) return arena.dupe(u8, ""); + const value = std.json.parseFromSliceLeaky(std.json.Value, arena, json, .{}) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.NotJson, + }; + if (value != .object) return error.NotAnObject; + var out: std.Io.Writer.Allocating = .init(arena); + writeZonObject(&out.writer, value.object, enum_keys) catch return error.OutOfMemory; + return out.written(); +} + +fn writeZonObject(w: *std.Io.Writer, object: std.json.ObjectMap, enum_keys: []const []const u8) std.Io.Writer.Error!void { + try w.writeAll(".{"); + var it = object.iterator(); + var first = true; + while (it.next()) |entry| { + if (!first) try w.writeByte(','); + first = false; + try w.print(".{f}=", .{std.zig.fmtId(entry.key_ptr.*)}); + const as_enum = for (enum_keys) |k| { + if (std.mem.eql(u8, k, entry.key_ptr.*)) break true; + } else false; + if (as_enum and entry.value_ptr.* == .string) { + try w.print(".{f}", .{std.zig.fmtId(entry.value_ptr.string)}); + } else try writeZon(w, entry.value_ptr.*); + } + try w.writeByte('}'); +} + +fn writeZon(w: *std.Io.Writer, value: std.json.Value) std.Io.Writer.Error!void { + switch (value) { + .null => try w.writeAll("null"), + .bool => |b| try w.writeAll(if (b) "true" else "false"), + .integer => |n| try w.print("{d}", .{n}), + // ZON reads `-0` as an integer it refuses; `-0.0` it takes. + .float => |f| if (f == 0 and std.math.signbit(f)) try w.writeAll("-0.0") else try w.print("{d}", .{f}), + .number_string => |s| try w.writeAll(s), + .string => |s| try w.print("\"{f}\"", .{std.zig.fmtString(s)}), + .array => |a| { + try w.writeAll(".{"); + for (a.items, 0..) |item, i| { + if (i > 0) try w.writeByte(','); + try writeZon(w, item); + } + try w.writeByte('}'); + }, + .object => |o| try writeZonObject(w, o, &.{}), + } +} + +/// `zon`, a command's result, as JSON. Empty or absent is `null`. +pub fn jsonFromZon(arena: std.mem.Allocator, zon: ?[]const u8) Error![]u8 { + const text = zon orelse return arena.dupe(u8, "null"); + if (std.mem.trim(u8, text, " \t\r\n").len == 0) return arena.dupe(u8, "null"); + const source = try arena.dupeSentinel(u8, text, 0); + const ast = try std.zig.Ast.parse(arena, source, .{ .mode = .zon }); + if (ast.errors.len > 0) return error.NotZon; + const zoir = try std.zig.ZonGen.generate(arena, ast, .{ .parse_str_lits = true }); + if (zoir.hasCompileErrors()) return error.NotZon; + var out: std.Io.Writer.Allocating = .init(arena); + writeJson(&out.writer, &zoir, .root) catch |err| return switch (err) { + error.NotUtf8 => error.NotUtf8, + error.WriteFailed => error.OutOfMemory, + }; + return out.written(); +} + +/// JSON strings are UTF-8, and std copies other bytes through as they are: a result holding them +/// is refused rather than sent as a body no client can parse. +fn writeString(w: *std.Io.Writer, s: []const u8) (std.Io.Writer.Error || error{NotUtf8})!void { + if (!std.unicode.utf8ValidateSlice(s)) return error.NotUtf8; + try std.json.Stringify.encodeJsonString(s, .{}, w); +} + +fn writeJson(w: *std.Io.Writer, zoir: *const std.zig.Zoir, index: std.zig.Zoir.Node.Index) (std.Io.Writer.Error || error{NotUtf8})!void { + switch (index.get(zoir)) { + .true => try w.writeAll("true"), + .false => try w.writeAll("false"), + // JSON has no infinities or NaN. + .null, .pos_inf, .neg_inf, .nan => try w.writeAll("null"), + .int_literal => |n| switch (n) { + .small => |s| try w.print("{d}", .{s}), + .big => |b| try w.print("{f}", .{b}), + }, + // Beyond f64 (`1e400`) is an infinity, which JSON cannot say either. + .float_literal => |f| { + const d: f64 = @floatCast(f); + if (std.math.isFinite(d)) try w.print("{d}", .{d}) else try w.writeAll("null"); + }, + .char_literal => |c| try w.print("{d}", .{c}), + .enum_literal => |name| try writeString(w, name.get(zoir)), + .string_literal => |s| try writeString(w, s), + // `.{}` is an empty struct as much as an empty tuple; an object is the likelier reading + // of a command's result. + .empty_literal => try w.writeAll("{}"), + .array_literal => |range| { + try w.writeByte('['); + for (0..range.len) |i| { + if (i > 0) try w.writeByte(','); + try writeJson(w, zoir, range.at(@intCast(i))); + } + try w.writeByte(']'); + }, + .struct_literal => |s| { + try w.writeByte('{'); + for (s.names, 0..) |name, i| { + if (i > 0) try w.writeByte(','); + try writeString(w, name.get(zoir)); + try w.writeByte(':'); + try writeJson(w, zoir, s.vals.at(@intCast(i))); + } + try w.writeByte('}'); + }, + } +} + +test "arguments: JSON object to a ZON struct literal" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + try std.testing.expectEqualStrings("", try zonFromJson(a, " \n", &.{})); + try std.testing.expectEqualStrings(".{}", try zonFromJson(a, "{}", &.{})); + try std.testing.expectEqualStrings( + ".{.line=12,.path=\"a \\\"b\\\".zig\",.on=true,.none=null,.xs=.{1,2.5},.at=.{.x=1}}", + try zonFromJson(a, "{\"line\":12,\"path\":\"a \\\"b\\\".zig\",\"on\":true,\"none\":null,\"xs\":[1,2.5],\"at\":{\"x\":1}}", &.{}), + ); + // A parameter that is an enumeration takes the string as its tag; a key that needs quoting is. + try std.testing.expectEqualStrings( + ".{.mode=.careful,.label=\"careful\",.@\"two words\"=1}", + try zonFromJson(a, "{\"mode\":\"careful\",\"label\":\"careful\",\"two words\":1}", &.{"mode"}), + ); + try std.testing.expectError(error.NotJson, zonFromJson(a, "{line: 12}", &.{})); + // Negative zero as ZON reads it. + const neg = try zonFromJson(a, "{\"x\":-0.0}", &.{}); + try std.testing.expectEqualStrings(".{.x=-0.0}", neg); + var diag: std.zon.parse.Diagnostics = undefined; + _ = try std.zon.parse.fromSlice(struct { x: f64 }, .{ .gpa = a, .arena = a, .source = try a.dupeSentinel(u8, neg, 0), .diagnostics = &diag }); + try std.testing.expectError(error.NotAnObject, zonFromJson(a, "[1]", &.{})); +} + +test "results: ZON to JSON" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + try std.testing.expectEqualStrings("null", try jsonFromZon(a, null)); + try std.testing.expectEqualStrings("null", try jsonFromZon(a, "")); + try std.testing.expectEqualStrings("{}", try jsonFromZon(a, ".{}")); + try std.testing.expectEqualStrings( + "{\"path\":\"a\\nb\",\"line\":12,\"big\":123456789012345678901234567890,\"f\":0.5,\"mode\":\"careful\",\"xs\":[1,true,null],\"c\":97}", + try jsonFromZon(a, ".{ .path = \"a\\nb\", .line = 12, .big = 123456789012345678901234567890, .f = 0.5, .mode = .careful, .xs = .{ 1, true, null }, .c = 'a' }"), + ); + try std.testing.expectEqualStrings("\"text\"", try jsonFromZon(a, "\"text\"")); + try std.testing.expectError(error.NotZon, jsonFromZon(a, ".{ .x = }")); + // What JSON cannot say: an infinity is null, bytes that are not UTF-8 are refused. + try std.testing.expectEqualStrings("{\"f\":null,\"g\":-1.5}", try jsonFromZon(a, ".{ .f = 1e400, .g = -1.5 }")); + try std.testing.expectError(error.NotUtf8, jsonFromZon(a, ".{ .s = \"\\x00\\xff\" }")); +} + +test "round trip: what goes in comes back" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const json = "{\"s\":\"\\u00e9 \\\\ \\t\",\"n\":-3,\"o\":{\"xs\":[]}}"; + const zon = try zonFromJson(a, json, &.{}); + // An empty array comes back as `{}`: ZON's `.{}` does not say which it was. + try std.testing.expectEqualStrings("{\"s\":\"é \\\\ \\t\",\"n\":-3,\"o\":{\"xs\":{}}}", try jsonFromZon(a, zon)); +} diff --git a/app/control/routes.zig b/app/control/routes.zig new file mode 100644 index 000000000..b32d957ab --- /dev/null +++ b/app/control/routes.zig @@ -0,0 +1,195 @@ +//! What the control server answers, on the UI thread: fizzy's identity and its commands +//! (plans/CONTROL_SERVER_PLAN.md, "The API"). The API is the commands every loaded plugin +//! registered, with the parameters each declared; nothing registers a route. +//! +//! GET / the app, its version, the SDK it was built against +//! GET /commands every command: id, title, owner, whether it is enabled, its params +//! GET /commands/{id} one of them +//! POST /commands/{id} run it: the body is its arguments, the response its result +//! +//! Bodies are JSON, converted at this edge (`json.zig`). A request sent as `application/zon` is +//! passed through as ZON and answered in ZON. Errors are HTTP's: 404 for an unknown command, 400 +//! for arguments that do not fit, 409 for a command not enabled now, 500 for one that ran and +//! failed, each with `{"error": "…"}`. +const std = @import("std"); +const sdk = @import("fizzy_sdk"); +const AppInfo = @import("../AppInfo.zig"); +const json = @import("json.zig"); +const Server = @import("Server.zig"); + +pub const Answer = struct { + status: std.http.Status, + /// Static: a string literal. + content_type: []const u8 = "application/json", + body: []const u8, +}; + +const zon_type = "application/zon"; + +/// Answer what waits on `server`. On the UI thread, once a frame, before plugins' `beginFrame`, so +/// a command run here is seen by the frame that follows. +pub fn answerPending(server: *Server, host: *sdk.Host) void { + var taken: std.ArrayListUnmanaged(*Server.Request) = .empty; + defer taken.deinit(server.gpa); + server.take(&taken); + for (taken.items) |req| { + var arena: std.heap.ArenaAllocator = .init(server.gpa); + defer arena.deinit(); + const a = handle(host, req.method, req.path(), req.content_type, req.body, arena.allocator()); + req.answer(server, a.status, a.content_type, a.body); + } +} + +/// Answer one request. Everything returned lives in `arena`. +pub fn handle(host: *sdk.Host, method: std.http.Method, path: []const u8, content_type: ?[]const u8, body: []const u8, arena: std.mem.Allocator) Answer { + return route(host, method, path, content_type, body, arena) catch .{ + .status = .internal_server_error, + .content_type = "text/plain", + .body = "out of memory\n", + }; +} + +fn route(host: *sdk.Host, method: std.http.Method, path: []const u8, content_type: ?[]const u8, body: []const u8, arena: std.mem.Allocator) error{OutOfMemory}!Answer { + const trimmed = std.mem.trimEnd(u8, path, "/"); + if (trimmed.len == 0) { + if (method != .GET and method != .HEAD) return notAllowed(arena, "GET"); + return ok(arena, .{ + .name = AppInfo.current.name, + .version = AppInfo.current.version, + .sdk_version = try std.fmt.allocPrint(arena, "{f}", .{sdk.version.sdk_version}), + }); + } + if (std.mem.eql(u8, trimmed, "/commands")) { + if (method != .GET and method != .HEAD) return notAllowed(arena, "GET"); + var list: std.ArrayListUnmanaged(CommandInfo) = .empty; + for (host.commands.items) |*c| try list.append(arena, try describe(c, arena)); + return ok(arena, list.items); + } + const prefix = "/commands/"; + if (std.mem.startsWith(u8, trimmed, prefix)) { + const id = std.Uri.percentDecodeInPlace(try arena.dupe(u8, trimmed[prefix.len..])); + const c = host.command(id) orelse + return failure(arena, .not_found, try std.fmt.allocPrint(arena, "no command {s}", .{id})); + return switch (method) { + .GET, .HEAD => ok(arena, try describe(c, arena)), + .POST => call(host, c, content_type, body, arena), + else => notAllowed(arena, "GET, POST"), + }; + } + return failure(arena, .not_found, try std.fmt.allocPrint(arena, "nothing at {s}", .{path})); +} + +fn call(host: *sdk.Host, c: *const sdk.Command, content_type: ?[]const u8, body: []const u8, arena: std.mem.Allocator) error{OutOfMemory}!Answer { + const as_zon = isZon(content_type); + const args = if (as_zon) body else args: { + var enum_keys: std.ArrayListUnmanaged([]const u8) = .empty; + for (c.params) |p| if (p.kind == .enumeration) try enum_keys.append(arena, p.key); + break :args json.zonFromJson(arena, body, enum_keys.items) catch |err| return switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.NotAnObject => failure(arena, .bad_request, "the arguments are a JSON object keyed by the command's params"), + else => failure(arena, .bad_request, "the body is not JSON (send ZON as application/zon)"), + }; + }; + switch (host.callCommand(c.id, args, arena)) { + .ok => |result| { + if (as_zon) return .{ .status = .ok, .content_type = zon_type, .body = result orelse "null" }; + const out = json.jsonFromZon(arena, result) catch |err| return switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.NotUtf8 => failure(arena, .internal_server_error, "the command's result holds text that is not UTF-8 (ask for application/zon)"), + else => failure(arena, .internal_server_error, "the command's result is not ZON"), + }; + return .{ .status = .ok, .body = out }; + }, + .unknown => return failure(arena, .not_found, try std.fmt.allocPrint(arena, "no command {s}", .{c.id})), + .disabled => return failure(arena, .conflict, "the command is not enabled now"), + .bad_args => |msg| return failure(arena, .bad_request, msg), + .failed => |msg| return failure(arena, .internal_server_error, msg), + } +} + +fn isZon(content_type: ?[]const u8) bool { + const t = content_type orelse return false; + const bare = std.mem.trim(u8, t[0 .. std.mem.indexOfScalar(u8, t, ';') orelse t.len], " "); + return std.ascii.eqlIgnoreCase(bare, zon_type); +} + +const CommandInfo = struct { + id: []const u8, + title: []const u8, + owner: ?[]const u8, + /// `external`: a person finds it too; `internal`: called by id only. + visibility: []const u8, + enabled: bool, + params: []const ParamInfo, +}; + +const ParamInfo = struct { + key: []const u8, + label: []const u8, + description: []const u8, + /// `bool`, `int`, `float`, `string`, `enum`, `color` or `zon` (anything else ZON can say). + kind: []const u8, + required: bool, + /// The default; absent when required or not simple to write. + default: ?std.json.Value = null, + min: ?f64 = null, + max: ?f64 = null, + choices: ?[]const []const u8 = null, +}; + +fn describe(c: *const sdk.Command, arena: std.mem.Allocator) error{OutOfMemory}!CommandInfo { + const params = try arena.alloc(ParamInfo, c.params.len); + for (c.params, params) |p, *out| { + out.* = .{ + .key = p.key, + .label = p.label, + .description = p.description, + .kind = switch (p.kind) { + .bool => "bool", + .int => "int", + .float => "float", + .string => "string", + .enumeration => "enum", + .color => "color", + .other => "zon", + }, + .required = p.required, + }; + switch (p.kind) { + .int => |k| { + out.min = @floatFromInt(k.min); + out.max = @floatFromInt(k.max); + }, + .float => |k| { + out.min = k.min; + out.max = k.max; + }, + .enumeration => |k| out.choices = k.choices, + else => {}, + } + if (p.default) |d| if (json.jsonFromZon(arena, d)) |text| { + out.default = std.json.parseFromSliceLeaky(std.json.Value, arena, text, .{}) catch null; + } else |_| {}; + } + const enabled = if (c.isEnabled) |f| if (c.owner) |o| f(o.state) else false else true; + return .{ + .id = c.id, + .title = c.title, + .owner = if (c.owner) |o| o.id else null, + .visibility = @tagName(c.visibility), + .enabled = enabled, + .params = params, + }; +} + +fn ok(arena: std.mem.Allocator, value: anytype) error{OutOfMemory}!Answer { + return .{ .status = .ok, .body = try std.json.Stringify.valueAlloc(arena, value, .{ .emit_null_optional_fields = false }) }; +} + +fn failure(arena: std.mem.Allocator, status: std.http.Status, message: []const u8) error{OutOfMemory}!Answer { + return .{ .status = status, .body = try std.json.Stringify.valueAlloc(arena, .{ .@"error" = message }, .{}) }; +} + +fn notAllowed(arena: std.mem.Allocator, allowed: []const u8) error{OutOfMemory}!Answer { + return failure(arena, .method_not_allowed, try std.fmt.allocPrint(arena, "use {s}", .{allowed})); +} diff --git a/app/history_transport.zig b/app/history_transport.zig index 7aae14312..7578f8b95 100644 --- a/app/history_transport.zig +++ b/app/history_transport.zig @@ -76,6 +76,70 @@ pub const Repeat = struct { } }; +/// A trim of a history of `n` steps (`TrimBar`): the steps from after `from` through `to` are +/// kept, at least one, as macOS's trim keeps the part of a recording between its two handles. +pub const Trim = struct { + from: usize, + to: usize, + n: usize, + + pub fn all(n: usize) Trim { + return .{ .from = 0, .to = n, .n = n }; + } + + /// What a press at `x` takes hold of on a line from `x0` to `x1`: the nearer handle within + /// `slop` of the press, else the playhead. + pub fn grab(self: Trim, x: f32, x0: f32, x1: f32, slop: f32) Grip { + const xf = stopX(self.from, x0, x1, self.n); + const xt = stopX(self.to, x0, x1, self.n); + const df = @abs(x - xf); + const dt = @abs(x - xt); + if (@min(df, dt) > slop) return .head; + // Both as close (a range of one step on a long line): the side the press is on. + if (df == dt) return if (x < xf) .from else .to; + return if (df < dt) .from else .to; + } + + /// Move what `grip` holds to `stop`, kept inside the history and at least one step apart. + /// Returns the stop the document shows: the handle's, or the playhead's inside the range. + pub fn drag(self: *Trim, grip: Grip, stop: usize) usize { + switch (grip) { + .from => { + self.from = @min(stop, self.to - 1); + return self.from; + }, + .to => { + self.to = @max(@min(stop, self.n), self.from + 1); + return self.to; + }, + .head => return std.math.clamp(stop, self.from, self.to), + } + } + + pub const Grip = enum { from, to, head }; +}; + +test "a trim keeps one step at least, its handles inside the history" { + var t: Trim = .all(10); + try std.testing.expectEqual(@as(usize, 9), t.drag(.from, 12)); + try std.testing.expectEqual(@as(usize, 10), t.drag(.to, 3)); + t = .all(10); + try std.testing.expectEqual(@as(usize, 4), t.drag(.to, 4)); + try std.testing.expectEqual(@as(usize, 3), t.drag(.from, 7)); + try std.testing.expectEqual(@as(usize, 4), t.drag(.head, 9)); + try std.testing.expectEqual(@as(usize, 3), t.drag(.head, 0)); +} + +test "a press takes the nearer handle, else the playhead" { + const t: Trim = .{ .from = 2, .to = 8, .n = 10 }; + // Stops 10 apart over 0..100. + try std.testing.expectEqual(Trim.Grip.from, t.grab(22, 0, 100, 5)); + try std.testing.expectEqual(Trim.Grip.to, t.grab(79, 0, 100, 5)); + try std.testing.expectEqual(Trim.Grip.head, t.grab(50, 0, 100, 5)); + const one: Trim = .{ .from = 5, .to = 6, .n = 1000 }; + try std.testing.expectEqual(Trim.Grip.from, one.grab(0.4, 0, 100, 5)); +} + /// The step a point `x` on a line from `x0` to `x1` means, in a history of `n` steps: how many /// of them are applied, 0 (before the first) to `n`. The line is split into `n + 1` equal stops, /// the point taking the nearest. diff --git a/app/root.zig b/app/root.zig index e87fbf614..f37618e4f 100644 --- a/app/root.zig +++ b/app/root.zig @@ -44,6 +44,8 @@ pub const ActivityFeed = @import("ActivityFeed.zig"); pub const Tracks = @import("Tracks.zig"); /// A document's transport: its history as a line at the bottom of its pane, undo and redo as seeks. pub const HistoryTransport = @import("HistoryTransport.zig"); +pub const TrimBar = @import("TrimBar.zig"); +pub const history_transport = @import("history_transport.zig"); /// Key chords → command ids: the table, its ZON form, chord matching, and the dvui adapter. pub const keymap = struct { @@ -103,6 +105,17 @@ pub const update = struct { pub const update_notify = @import("update/update_notify.zig"); }; +/// The control server: HTTP on a Unix socket in the config folder, the app's commands as its API, +/// so `curl --unix-socket` drives the app with no agent (plans/CONTROL_SERVER_PLAN.md). Native +/// only. The app starts a `Server`, calls `routes.answerPending` each frame and stops it at quit. +pub const control = struct { + pub const Server = @import("control/Server.zig"); + pub const routes = @import("control/routes.zig"); + pub const json = @import("control/json.zig"); + /// The socket's name in the config folder. + pub const socket_name = "control.sock"; +}; + /// Quitting the way the app always quits — unsaved documents asked about first — then starting /// again: for a setting the app takes only at launch, and for installing a downloaded `update`. /// The app asks with `request`, runs `tick` each frame and `relaunch` at the end of teardown. @@ -171,4 +184,6 @@ test { _ = store.Loader; _ = Tracks; _ = HistoryTransport; + _ = control.routes; + _ = TrimBar; } diff --git a/build.zig b/build.zig index 26b3365bb..6994cf01d 100644 --- a/build.zig +++ b/build.zig @@ -20,7 +20,18 @@ pub fn build(b: *std.Build) !void { const macos_notary_profile = b.option([]const u8, "macos-notary-profile", "notarytool keychain profile name (run `xcrun notarytool store-credentials ` first)"); const macos_sdk = b.option([]const u8, "macos-sdk", "macOS SDK path for an explicit -Dtarget=*-macos (default: `xcrun --sdk macosx --show-sdk-path`)"); - const target = b.standardTargetOptions(.{}); + // Windows 10 1803 at least: AF_UNIX arrived there, and std offers it only to a target whose + // minimum is that (`std.Io.net.has_unix_sockets`) — the control server's socket + // (`app/control/Server.zig`). Every Windows still supported by Microsoft is newer. + const target = target: { + const asked = b.standardTargetOptions(.{}); + if (asked.result.os.tag != .windows or asked.result.os.isAtLeast(.windows, .win10_rs4) == true) break :target asked; + if (asked.result.os.version_range.windows.max.isAtLeast(.win10_rs4) != true) + std.debug.panic("fizzy needs Windows 10 1803 (win10_rs4) or newer; -Dtarget asks for an older range", .{}); + var query = asked.query; + query.os_version_min = .{ .windows = .win10_rs4 }; + break :target b.resolveTargetQuery(query); + }; const optimize = b.standardOptimizeOption(.{}); const base: @import("build/app.zig").Options = .{ diff --git a/build/app.zig b/build/app.zig index bbf974d06..d3c529f63 100644 --- a/build/app.zig +++ b/build/app.zig @@ -661,6 +661,12 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // step a point on its line is. std-only (see history_transport.zig); `HistoryTransport` // draws it. .{ "fizzy-history-transport-tests", "app/history_transport.zig" }, + // The control server's JSON at its edge, ZON inside, and its transport: a request over + // the socket reaching the UI thread and its answer going back. std-only (see + // control/json.zig, control/Server.zig); `control/routes.zig` answers over the Host, + // covered by `tests/integration.zig`. + .{ "fizzy-control-json-tests", "app/control/json.zig" }, + .{ "fizzy-control-server-tests", "app/control/Server.zig" }, // One material on two sliders: what every glass is at the window's opacity and roughness, // the OS's form and the app's. std-only (see glass_look.zig); `LiquidField` and `Popout` // apply it. diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index e9cf18e17..bdd003dd5 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -305,7 +305,13 @@ plugin that applies it and its encoding: `.{ .path = "sprite.pixi", .text = { check.live = true; @@ -356,6 +357,7 @@ const stage_vtable: automation.Stage.VTable = .{ .restore = restore, .release = release, .fingerprint = fingerprint, + .step = step, }; /// A live tape played by nobody in particular, or a demo with no name. @@ -400,6 +402,31 @@ pub fn setLiveAuthor(self: *Demo, who: ?sdk.Author) void { self.live_author = .{ .kind = a.kind, .plugin = plugin, .session = a.session, .label = label }; } +/// What a tape's steps need of fizzy (`Tape.Check.steps`): the owner they name, loaded, writing +/// steps in the format they were recorded in. +pub fn stepsCheck(owner: []const u8, format: u32) bool { + const plugin = fizzy.editor().app.host.pluginById(owner) orelse return false; + return plugin.step_format != 0 and plugin.step_format == format and plugin.vtable.applyStep != null; +} + +/// A step of a document the keyframe opened, applied by its owner (`Tape.Step`). +fn step(ctx: *anyopaque, st: automation.Tape.Step, ms: u32) void { + const self = from(ctx); + const editor = self.editor; + const root = self.mount orelse return; + const path = core.paths.join(editor.app.gpa, root, st.file) catch return; + defer editor.app.gpa.free(path); + const doc = editor.docFromPath(path) orelse { + dvui.log.warn("demo: a step of {s}, which is not open", .{path}); + return; + }; + if (doc.owner.applyStep(doc, st.effect, st.motion, ms)) return; + // The document no longer is what the step was made on: every step after would miss too, so + // the tape stops where the viewer can see it went wrong. + self.player.pause(); + dvui.toast(@src(), .{ .message = "This tape's steps no longer fit its document: it stopped here." }); +} + fn from(ctx: *anyopaque) *Demo { return @ptrCast(@alignCast(ctx)); } diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 2c1cca681..114ddf86d 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -95,6 +95,7 @@ pub const FileLoadJob = workbench_mod.FileLoadJob; pub const sdk = fizzy.sdk; /// Demo automation: fizzy's stage for `app.automation`, and the demos it ships with. pub const Demo = @import("Demo.zig"); +const TapeTabs = @import("TapeTabs.zig"); /// A float taken out of the main window into an OS window of its own: on by default on macOS, /// `FIZZY_POPOUT=1` elsewhere (`plans/POPOUT_WINDOWS_PLAN.md`). pub const Popout = @import("Popout.zig"); @@ -130,6 +131,11 @@ command_palette: @import("CommandPalette.zig") = .{}, /// The demo player and fizzy's stage for it. Attached in `postInit`, at the Editor's final /// address (the stage's context is this field). demo: Demo = .detached, +/// Tapes playing in tabs of their own (`Demo: Play a Tape…`, the transport's share). +tape_tabs: TapeTabs = .{}, +/// A document whose transport's share was pressed: exported at the top of the next frame, before +/// anything draws, since the export walks the document's history back and forth. +share_pending: ?u64 = null, /// Rebuilt plugins opening off the UI thread, swapped in once open (`PluginReloads`). plugin_reloads: @import("PluginReloads.zig") = .{}, @@ -2263,6 +2269,23 @@ pub fn postInit(editor: *Editor) !void { // `editor.demo` was attached above, at its final address. try editor.app.host.registerService(sdk.services.automation.Api, &editor.demo.service.api, null); + // The control server: the app's commands over HTTP on `/control.sock`, while + // `FIZZY_CONTROL=1` asks for it (plans/CONTROL_SERVER_PLAN.md: dark until step 4). + if (comptime builtin.target.cpu.arch != .wasm32) control: { + const v = std.process.Environ.getAlloc(fizzy.core.platform.processEnviron(), editor.app.gpa, "FIZZY_CONTROL") catch break :control; + defer editor.app.gpa.free(v); + if (!std.mem.eql(u8, v, "1")) break :control; + const path = std.fs.path.join(editor.app.gpa, &.{ editor.app.config_folder, @import("app").control.socket_name }) catch break :control; + editor.app.control = .{ .gpa = editor.app.gpa, .io = dvui.io, .socket_path = path, .wake = wakeEventLoop }; + editor.app.control.?.start() catch |err| { + dvui.log.warn("control: could not listen on {s}: {s}", .{ path, @errorName(err) }); + editor.app.gpa.free(path); + editor.app.control = null; + break :control; + }; + dvui.log.info("control: listening on {s}", .{path}); + } + // Live external-edit reconciliation for settings.zon + dropped-in plugin discovery (see // R11/R12 in plans/PLUGIN_MANIFEST_PLAN.md). Must happen here, in `postInit`, not `init` — // nightwatch retains `&editor.app.settings_watcher.handler`, so `editor` has to already be at @@ -2979,10 +3002,84 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { } } // Its history, while undo or redo or the pointer keep it: not while someone else holds it. - if (claim) |c| drawClaim(editor, c, claim_pill.?, doc.owner.documentPath(doc)) else ds.transport.draw(doc, canvas.data().borderRectScale().r, @truncate(ds.doc_id)); + // A shared tape being trimmed has the trim bar instead. + if (claim) |c| drawClaim(editor, c, claim_pill.?, doc.owner.documentPath(doc)) else if (editor.tape_tabs.trimOf(doc.id, dvui.currentWindow().arena())) |trim| { + TapeTabs.drawTrim(doc, trim[0], trim[1], canvas.data().borderRectScale().r, @truncate(ds.doc_id)); + } else { + const shareable = if (editor.app.tracks) |*t| if (t.trackOf(doc.id)) |tr| tr.steps.items.len > 0 else false else false; + const pressed = ds.transport.draw(doc, canvas.data().borderRectScale().r, @truncate(ds.doc_id), .{ .share = shareable }); + if (pressed.share) { + editor.share_pending = doc.id; + dvui.refresh(null, @src(), null); + } + } return .ok; } +/// How `doc` was made, as a tape to trim: its whole track exported (`Tracks.exportTape`) and +/// opened in a tab of its own, played in at once, for the person to trim and share +/// (`TapeTabs`, `TrimBar`). Nothing is written until they share. The transport's share. +pub fn shareDocument(editor: *Editor, doc: sdk.DocHandle) void { + const gpa = editor.app.gpa; + const t = &(editor.app.tracks orelse return); + const bytes = t.exportTape(&editor.app.host, doc, gpa, .{}) catch |err| { + dvui.log.warn("share: could not export {s}: {t}", .{ doc.owner.documentPath(doc), err }); + _ = editor.noteOnDocument(doc.id, "Could not share"); + return; + }; + defer gpa.free(bytes); + // Whose each step is, for the trim to say. + const arena = dvui.currentWindow().arena(); + const steps = (t.trackOf(doc.id) orelse return).steps.items; + const by = arena.alloc(u8, steps.len) catch return; + var names: std.ArrayListUnmanaged([]const u8) = .empty; + var authors: std.ArrayListUnmanaged(sdk.Author) = .empty; + for (steps, by) |r, *b| { + const who = editor.app.host.stepAuthor(doc.id, r.id); + const i = for (authors.items, 0..) |a, i| { + if (a.same(who)) break i; + } else i: { + if (authors.items.len == 255) break :i 254; + authors.append(arena, who) catch return; + names.append(arena, switch (who.kind) { + .person => "you", + .unattributed => "someone unknown", + else => if (who.label.len > 0) who.label else @tagName(who.kind), + }) catch return; + break :i authors.items.len - 1; + }; + b.* = @intCast(i); + } + editor.tape_tabs.open(editor, bytes, .{ .trim = .{ .by = by, .names = names.items } }) catch |err| { + dvui.log.warn("share: could not open the tape: {t}", .{err}); + _ = editor.noteOnDocument(doc.id, "Could not open the tape"); + }; +} + +/// Say `message` on the document's own panes, in the glass its commands show in +/// (`ActivityFeed`), rather than in a toast over the whole window. False while the document has +/// no panes yet (it is still opening). +pub fn noteOnDocument(editor: *Editor, doc_id: u64, message: []const u8) bool { + const ds = editor.doc_surfaces.get(doc_id) orelse return false; + ds.feed.push(message, dvui.frameTimeNS()); + dvui.refresh(null, @src(), null); + return true; +} + +/// Write a tape of `doc` to `out`, or (empty) to the config folder's `tapes/`, named for the +/// document and the time. Returns where it went. +pub fn keepTape(editor: *Editor, arena: std.mem.Allocator, doc: sdk.DocHandle, bytes: []const u8, out: []const u8) ![]const u8 { + const io = dvui.io; + const path = if (out.len > 0) out else path: { + const stem = std.fs.path.stem(doc.owner.documentPath(doc)); + const file = try std.fmt.allocPrint(arena, "{s}-{d}.tape", .{ stem, std.Io.Clock.real.now(io).toSeconds() }); + break :path try std.fs.path.join(arena, &.{ editor.app.config_folder, "tapes", file }); + }; + if (std.fs.path.dirname(path)) |dir| try std.Io.Dir.cwd().createDirPath(io, dir); + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = bytes }); + return path; +} + /// Record documents' tracks, or stop. Documents open now get one; those opened after too. pub fn setTracking(editor: *Editor, on: bool) void { const t = &(editor.app.tracks orelse return); @@ -3978,6 +4075,11 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.doc_io.tick(); // The document states tracks asked for since last frame, before anything draws. if (editor.app.tracks) |*t| t.tick(); + if (editor.share_pending) |id| { + editor.share_pending = null; + if (editor.app.open_files.get(id)) |doc| editor.shareDocument(doc); + } + editor.tape_tabs.tick(editor); editor.promoteEditedPreviews(); editor.window_opacity = if (dvui.themeGet().dark) editor.app.settings.window_opacity_dark else editor.app.settings.window_opacity_light; @@ -4129,6 +4231,10 @@ pub fn tick(editor: *Editor) !dvui.App.Result { defer t.end(); // What was announced since last frame first, so a plugin's frame sees what it heard. editor.app.host.deliverEvents(); + // Requests from the control server, so the frame that follows sees what they ran. + if (comptime builtin.target.cpu.arch != .wasm32) { + if (editor.app.control) |*c| @import("app").control.routes.answerPending(c, &editor.app.host); + } // A seek replays a tape's commands where nobody watches: not shown as activity. editor.app.host.activity_muted = editor.demo.player.state == .seeking; editor.showClaimed(); @@ -6236,6 +6342,7 @@ pub fn deinit(editor: *Editor) !void { // nothing it changed is written below. const demo_was_active = editor.demo.active(); editor.demo.deinit(); + editor.tape_tabs.deinit(editor); editor.black_box.deinit(editor.app.gpa); editor.app.host.recorder = null; if (editor.app.tracks) |*t| t.deinit(); @@ -6254,6 +6361,15 @@ pub fn deinit(editor: *Editor) !void { // this function still runs code that would otherwise query it — `saveSettingsRaw` below // reaches `writeMergedSettings`' `notifyPathChanged` (only on a real write, which is why // this crashed rarely rather than always). + // The control server first: what it still holds is answered "closing", before anything a + // command would reach goes. + if (comptime builtin.target.cpu.arch != .wasm32) { + if (editor.app.control) |*c| { + c.stop(); + editor.app.gpa.free(c.socket_path); + editor.app.control = null; + } + } if (editor.document_watcher) |*w| { w.stop(); editor.document_watcher = null; diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index 4d138bbbd..bb0caf2ab 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -155,8 +155,8 @@ const fizzy_commands = [_]FizzyCommand{ // No dvui bind name: its default comes from the profile table (`close_defaults`). .{ .id = "fizzy.close", .title = "Close", .bind = null, .run = cmdClose, .isEnabled = anyDocOpen, .params = ClosePath.params, .runWith = ClosePath.bind(cmdClosePath), .icon = icons.tvg.lucide.x }, .{ .id = "fizzy.saveAll", .title = "Save All", .bind = "save_all", .run = cmdSaveAll, .icon = icons.tvg.lucide.@"save-all" }, - .{ .id = "fizzy.undo", .title = "Undo", .bind = "undo", .run = cmdUndo, .isEnabled = cmdUndoEnabled, .icon = icons.tvg.lucide.undo }, - .{ .id = "fizzy.redo", .title = "Redo", .bind = "redo", .run = cmdRedo, .isEnabled = cmdRedoEnabled, .icon = icons.tvg.lucide.redo }, + .{ .id = "fizzy.undo", .title = "Undo", .bind = "undo", .run = cmdUndo, .isEnabled = anyDocOpen, .params = HistoryStep.params, .runWith = HistoryStep.bind(cmdUndoWith), .icon = icons.tvg.lucide.undo }, + .{ .id = "fizzy.redo", .title = "Redo", .bind = "redo", .run = cmdRedo, .isEnabled = anyDocOpen, .params = HistoryStep.params, .runWith = HistoryStep.bind(cmdRedoWith), .icon = icons.tvg.lucide.redo }, .{ .id = "fizzy.copy", .title = "Copy", .bind = "copy", .run = cmdCopy, .isEnabled = cmdCopyEnabled, .icon = icons.tvg.lucide.copy }, .{ .id = "fizzy.paste", .title = "Paste", .bind = "paste", .run = cmdPaste, .isEnabled = cmdPasteEnabled, .icon = icons.tvg.lucide.@"clipboard-paste" }, .{ .id = "fizzy.toggleExplorer", .title = "Toggle Explorer", .bind = "explorer", .run = cmdToggleExplorer, .icon = icons.tvg.lucide.@"panel-left" }, @@ -178,6 +178,7 @@ const fizzy_commands = [_]FizzyCommand{ .{ .id = "fizzy.capture", .title = "Capture the Last 30 Seconds", .bind = null, .run = cmdCapture, .params = CaptureArgs.params, .runWith = CaptureArgs.bind(cmdCaptureWith), .icon = icons.tvg.lucide.gauge }, .{ .id = "fizzy.tracks.record", .title = "Tracks: Record…", .bind = null, .run = cmdTracks, .params = TracksRecord.params, .runWith = TracksRecord.bind(cmdTracksRecord), .icon = icons.tvg.lucide.gauge }, .{ .id = "fizzy.tracks.replay", .title = "Tracks: Replay…", .bind = null, .run = cmdTracks, .params = TracksReplay.params, .runWith = TracksReplay.bind(cmdTracksReplay), .icon = icons.tvg.lucide.gauge }, + .{ .id = "fizzy.tracks.export", .title = "Tracks: Export as a Tape…", .bind = null, .run = cmdTracks, .params = TracksExport.params, .runWith = TracksExport.bind(cmdTracksExport), .icon = icons.tvg.lucide.gauge }, .{ .id = "fizzy.tracks", .title = "Tracks: Report", .bind = null, .run = cmdTracks, .params = TracksArgs.params, .runWith = TracksArgs.bind(cmdTracksWith), .icon = icons.tvg.lucide.gauge }, .{ .id = "fizzy.writeTrace", .title = "Write Trace", .bind = null, .run = cmdWriteTrace, .params = TraceArgs.params, .runWith = TraceArgs.bind(cmdWriteTraceTo), .icon = icons.tvg.lucide.gauge }, } ++ open_folder_commands ++ demo_commands ++ automation_commands; @@ -206,8 +207,9 @@ fn playTapeCallback(paths: ?[][:0]const u8) void { playTapeFile(fizzy.editor(), list[0]) catch |err| dvui.log.err("tape {s}: {t}", .{ list[0], err }); } -/// Plays a tape file in the player, with its transport: seek, chapters, play and pause. A tape -/// that opens on a keyframe sets the person's session aside and gives it back when it ends. +/// Plays a tape file: one document's track in a tab of its own (`TapeTabs`), anything else in the +/// player, with its transport: seek, chapters, play and pause. A tape that opens on a keyframe +/// sets the person's session aside and gives it back when it ends. fn cmdPlayTapePath(state: *anyopaque, args: TapePath.Args, call: *sdk.Command.Call) anyerror!void { if (args.path.len == 0) return cmdPlayTape(state); if (comptime is_web) return call.fail("a page plays a tape from its address (?demo=)", .{}); @@ -220,7 +222,12 @@ fn playTapeFile(editor: *Editor, path: []const u8) !void { const gpa = editor.app.gpa; const bytes = try std.Io.Dir.cwd().readFileAlloc(dvui.io, path, gpa, .limited(256 << 20)); defer gpa.free(bytes); - editor.demo.playTapeSoon(bytes); + // One document's track (a shared tape) plays in a tab of its own, beside the person's work; + // anything else on the demo stage. + editor.tape_tabs.open(editor, bytes, .{}) catch |err| switch (err) { + error.NotOneDocument, error.BadTape => editor.demo.playTapeSoon(bytes), + else => return err, + }; } const DemoSpeed = sdk.Command.Params(struct { @@ -412,6 +419,64 @@ fn cmdUndo(state: *anyopaque) anyerror!void { fn cmdRedo(state: *anyopaque) anyerror!void { try editorFromState(state).redo(); } + +const HistoryStep = sdk.Command.Params(struct { + path: sdk.Command.Arg([]const u8, .{ .description = "The open document, by its path. Empty: the active one." }) = .init(""), + expect: sdk.Command.Arg(u64, .{ .description = "The step you mean, by its id: for undo the last change, for redo the next one. Refused when it is not, so a step someone made since is never taken back. 0: whichever it is." }) = .init(0), +}); + +/// Enabled while any document is open, as text's by-path commands are: a call may name a document +/// that is not the active one, and says why when there is nothing to take. The menus grey Undo by +/// the active document's own (`menu_model.canUndo`). An enabled check that sees the call's target +/// is the control server's to decide (`plans/CONTROL_SERVER_PLAN.md`, "Commands that take a +/// document"). +fn cmdUndoWith(state: *anyopaque, args: HistoryStep.Args, call: *sdk.Command.Call) anyerror!void { + try historyStepWith(editorFromState(state), args, call, .undo); +} +fn cmdRedoWith(state: *anyopaque, args: HistoryStep.Args, call: *sdk.Command.Call) anyerror!void { + try historyStepWith(editorFromState(state), args, call, .redo); +} + +/// Undo or redo one step of a document named by path, or of the active one, as any caller: the +/// person's palette, a script, an agent. Over the owner's history line (`historyLine`, +/// `historyGoTo`), so it reaches a document that is not active. With `expect`, only when that +/// step is the one it would take, so a caller never takes back a step made since it looked +/// (`plans/SHARED_HISTORY_PLAN.md`, step 5). Answers the step and whose it was. +fn historyStepWith(editor: *Editor, args: HistoryStep.Args, call: *sdk.Command.Call, action: enum { undo, redo }) !void { + const doc = if (args.path.len == 0) + editor.activeDoc() orelse return call.fail("no document is active", .{}) + else + editor.docFromPath(args.path) orelse return call.fail("no open document at {s}", .{args.path}); + const line = doc.owner.historyLine(doc, call.arena) orelse { + // An owner with no line undoes the active document only, through its own command. + const active = editor.activeDoc(); + if (args.expect != 0 or active == null or active.?.id != doc.id) + return call.fail("{s} keeps no history line, so it can only undo the active document, with no expect", .{doc.owner.id}); + return switch (action) { + .undo => editor.undo(), + .redo => editor.redo(), + }; + }; + const host = &editor.app.host; + const step: sdk.Plugin.HistoryLine.Entry, const to: u64 = switch (action) { + .undo => blk: { + if (line.at == 0) return call.fail("nothing to undo", .{}); + break :blk .{ line.entries[line.at - 1], if (line.at >= 2) line.entries[line.at - 2].id else 0 }; + }, + .redo => blk: { + if (line.at >= line.entries.len) return call.fail("nothing to redo", .{}); + break :blk .{ line.entries[line.at], line.entries[line.at].id }; + }, + }; + const whose = host.stepAuthor(doc.id, step.id); + const by = if (whose.kind == .person) "the person" else if (whose.label.len > 0) whose.label else @tagName(whose.kind); + if (args.expect != 0 and args.expect != step.id) return call.fail( + "step {d} is not the one to {s}: that is step {d}, {s}, by {s}. The document changed since; fix it forward", + .{ args.expect, @tagName(action), step.id, step.title, by }, + ); + if (!doc.owner.historyGoTo(doc, to)) return call.fail("{s} could not {s} step {d}", .{ doc.owner.id, @tagName(action), step.id }); + try call.returns(.{ .step = step.id, .title = step.title, .by = by }); +} /// Set while `tick` is running a command because of a live key event. That event is never /// marked handled, so it goes on to reach the focused widget under its own steam — a clipboard /// command that also synthesized one would make the widget act twice. False for menu clicks and @@ -494,12 +559,6 @@ fn cmdFitView(state: *anyopaque) anyerror!void { fn cmdFitViewEnabled(state: *anyopaque) bool { return editorFromState(state).activeDocCommandEnabled("fitView"); } -fn cmdUndoEnabled(state: *anyopaque) bool { - return editorFromState(state).activeDocCommandEnabled("undo"); -} -fn cmdRedoEnabled(state: *anyopaque) bool { - return editorFromState(state).activeDocCommandEnabled("redo"); -} /// Enabled when *either* target of `clipboardVerb` can act: the active document, or a focused /// text input that the synthetic event would reach. Gating on the document alone would grey out /// Copy in the palette exactly when focus sits in a search box, which is when the fallback path @@ -612,6 +671,33 @@ fn cmdTracksReplay(state: *anyopaque, args: TracksReplay.Args, call: *sdk.Comman try call.returns(.{ .steps = @min(n, track.steps.items.len), .checkpoints = track.checkpoints.items.len }); } +const TracksExport = sdk.Command.Params(struct { + path: sdk.Command.Arg([]const u8, .{ .description = "The open document whose track to export, by its path. Empty: the active one." }) = .init(""), + out: sdk.Command.Arg([]const u8, .{ .description = "Where to write the tape. Empty: the config folder's tapes/, named for the document and the time." }) = .init(""), + real_time: sdk.Command.Arg(bool, .{ .description = "Keep every pause as long as it was. Off: a pause past 2 s plays as 0.6 s." }) = .init(false), + from: sdk.Command.Arg(u32, .{ .description = "Start after this many of the track's steps: the tape opens on the document as it was then. 0: where the track began." }) = .init(0), + to: sdk.Command.Arg(u32, .{ .description = "End after this many of the track's steps. 0: the last." }) = .init(0), +}); + +/// A document's track as a tape (`Tracks.exportTape`): how it was made, from where its track +/// began, to play anywhere (`fizzy.demo.playTape`). Returns where it went and what it holds. +fn cmdTracksExport(state: *anyopaque, args: TracksExport.Args, call: *sdk.Command.Call) anyerror!void { + if (comptime @import("builtin").target.cpu.arch == .wasm32) return call.fail("the web has no file to write a tape to yet", .{}); + const editor = editorFromState(state); + const t = &(editor.app.tracks orelse return call.fail("no tracks in this app", .{})); + const doc = (if (args.path.len == 0) editor.activeDoc() else editor.docFromPath(args.path)) orelse + return call.fail("no open document at {s}", .{if (args.path.len == 0) "(active)" else args.path}); + const gpa = editor.app.gpa; + const opts: @import("app").Tracks.ExportOptions = .{ .real_time = args.real_time, .from = args.from, .to = if (args.to == 0) null else args.to }; + const sum = t.summarize(&editor.app.host, doc, call.arena, opts) catch |err| return call.fail("could not sum up {s}'s track: {t}", .{ doc.owner.documentPath(doc), err }); + const bytes = t.exportTape(&editor.app.host, doc, gpa, opts) catch |err| return call.fail("could not export {s}: {t}", .{ doc.owner.documentPath(doc), err }); + defer gpa.free(bytes); + const out = editor.keepTape(call.arena, doc, bytes, args.out) catch |err| return call.fail("could not write the tape: {t}", .{err}); + const authors = try call.arena.alloc([]const u8, sum.authors.len); + for (sum.authors, authors) |a, *l| l.* = if (a.kind == .person) "the person" else if (a.label.len > 0) a.label else @tagName(a.kind); + try call.returns(.{ .path = out, .bytes = bytes.len, .steps = sum.steps, .from = sum.from, .to = sum.to, .recorded_ms = sum.recorded_ms, .played_ms = sum.played_ms, .authors = authors }); +} + fn cmdTracksRecord(state: *anyopaque, args: TracksRecord.Args, call: *sdk.Command.Call) anyerror!void { const editor = editorFromState(state); editor.setTracking(args.on); diff --git a/src/editor/TapeTabs.zig b/src/editor/TapeTabs.zig new file mode 100644 index 000000000..e9027c8c2 --- /dev/null +++ b/src/editor/TapeTabs.zig @@ -0,0 +1,474 @@ +//! Tapes played in a tab of their own: a document's track shared as a tape (`fizzy.tracks.export`) +//! opens beside the person's documents, never over them, and plays itself in. +//! +//! The tape's file (its keyframe's one document, as it was when the track began) is mounted +//! under a root of its own (`tape://`) and opened like any file; then each step is applied as +//! its moment comes, by the document's owner (`Plugin.applyStep`, at pace). Each step lands as an +//! entry on the document's history, so the document's transport plays back over what has played: +//! scrub back through it, step through it, as through any document's history. +//! +//! Unlike the demo stage, nothing of the person's is set aside or waits on their unsaved work: the +//! tape is a document among theirs. Closing it ends the playback and the mount. While nothing is +//! playing a frame costs one check of an empty list; while one is, a timer wakes it for the next +//! step and nothing more. +//! +//! **Trimming.** A tape opened to be shared (`OpenOptions.trim`, the transport's share) plays in +//! at once and stays in its tab for the person to trim, as macOS trims a recording: the pane's bar +//! is a `TrimBar`, the document showing wherever its handles are dragged, and play previews the +//! part kept. Share writes that part as a tape of its own (`trimmed`): the document's file where +//! the part begins, the steps from there, timed from its first. Nothing is written until then; +//! cancel closes the tab. +const TapeTabs = @This(); + +const std = @import("std"); +const dvui = @import("dvui"); +const fizzy = @import("../fizzy.zig"); +const core = fizzy.core; +const sdk = fizzy.sdk; +const app = @import("app"); +const automation = app.automation; +const Editor = @import("Editor.zig"); + +playing: std.ArrayListUnmanaged(*Playback) = .empty, +/// The next playback's number, for its root. +next: u32 = 1, + +const Playback = struct { + owned: automation.Tape.Owned, + mem: *core.vfs.Mem, + /// `tape://`: the tape's mount. + root: []u8, + /// The document's path under it. + path: []u8, + /// The document once it opened; null while it loads. + doc_id: ?u64 = null, + /// The next op to apply. + op: usize = 0, + /// When the document opened: the tape's time 0. + start_ns: i128 = 0, + /// When the tape was opened: a document that has not come by `load_limit_ns` after never + /// will (its load failed), and the playback lets its mount go. + opened_ns: i128, + /// Said on the tape's own tab once it shows (`Editor.noteOnDocument`); owned. + note: ?[]u8 = null, + /// Being trimmed before it is shared; null for a tape that only plays. + trim: ?*Trimming = null, +}; + +/// A tape in its tab, being trimmed (`TrimBar`). +pub const Trimming = struct { + span: app.history_transport.Trim = .all(0), + bar: app.TrimBar = .{}, + /// Each step's time and length in the tape, ms, in order. + at: []u32, + ms: []u32, + /// Who made each step, as an index into `names`; empty when not known. + by: []u8, + names: [][]u8, + /// The preview playing the part kept: when its first step played, as frame time. + playing_since: ?i128 = null, + /// Pressed on the bar, done at the next frame's top: sharing walks the document's history. + action: enum { none, share, cancel } = .none, + + fn deinit(self: *Trimming, gpa: std.mem.Allocator) void { + gpa.free(self.at); + gpa.free(self.ms); + gpa.free(self.by); + for (self.names) |n| gpa.free(n); + gpa.free(self.names); + } +}; + +/// Who made a shared track's steps, for the trim's summary: an author name per step, by index. +pub const Authors = struct { + by: []const u8, + names: []const []const u8, +}; + +pub const OpenOptions = struct { + /// Said on the tape's tab once it shows. + note: ?[]const u8 = null, + /// Open it to be trimmed and shared (the transport's share): played in at once, then trimmed. + trim: ?Authors = null, +}; + +const load_limit_ns: i128 = 30 * std.time.ns_per_s; + +pub fn deinit(self: *TapeTabs, editor: *Editor) void { + for (self.playing.items) |p| free(editor, p); + self.playing.deinit(editor.app.gpa); +} + +pub const OpenError = error{ + /// Not a tape fizzy reads, or steps of an owner not loaded or of another format. + BadTape, + /// A tape of anything but one document's steps (a demo, a session of several): the demo stage + /// plays those (`fizzy.demo.playTape`). + NotOneDocument, + OutOfMemory, +}; + +/// Open the tape in `bytes` in a tab of its own and play it, or play it in at once and trim it +/// (`opts.trim`). The bytes may be let go of at once. +pub fn open(self: *TapeTabs, editor: *Editor, bytes: []const u8, opts: OpenOptions) (OpenError || anyerror)!void { + const gpa = editor.app.gpa; + var check = automation.Input.check; + check.steps = @import("Demo.zig").stepsCheck; + var owned = automation.Tape.load(gpa, bytes, check) catch return error.BadTape; + errdefer owned.deinit(); + const tape = owned.tape; + if (tape.keyframes.len != 1 or tape.keyframes[0].files.len != 1) return error.NotOneDocument; + for (tape.ops[1..]) |op| switch (op.do) { + .step, .wait => {}, + else => return error.NotOneDocument, + }; + const file = tape.keyframes[0].files[0]; + + // A root nothing holds: not a mount, and no document open under it (one a crash's recovery + // or a restart brought back from an earlier run). + const root = root: while (true) : (self.next += 1) { + const r = try std.fmt.allocPrint(gpa, "tape://{d}", .{self.next}); + if (!editor.app.file_table.isMounted(r) and !openUnder(editor, r)) break :root r; + gpa.free(r); + }; + errdefer gpa.free(root); + const path = try core.paths.join(gpa, root, file.path); + errdefer gpa.free(path); + const mem = try gpa.create(core.vfs.Mem); + errdefer gpa.destroy(mem); + mem.* = try .init(gpa); + errdefer mem.deinit(); + const at = try std.fmt.allocPrint(gpa, "/{s}", .{file.path}); + defer gpa.free(at); + try mem.put(at, file.text); + try editor.app.host.mount(root, mem.fs()); + errdefer editor.app.host.unmount(root); + + const p = try gpa.create(Playback); + errdefer gpa.destroy(p); + p.* = .{ .owned = owned, .mem = mem, .root = root, .path = path, .opened_ns = dvui.frameTimeNS(), .note = if (opts.note) |n| try gpa.dupe(u8, n) else null }; + errdefer if (p.note) |n| gpa.free(n); + if (opts.trim) |authors| p.trim = try trimming(gpa, tape, authors); + errdefer if (p.trim) |t| { + t.deinit(gpa); + gpa.destroy(t); + }; + _ = try editor.openFilePath(path, editor.workbench.currentGroupingID()); + // Last, once nothing above can fail: the list owns it from here. + try self.playing.append(gpa, p); + self.next += 1; + dvui.refresh(null, @src(), null); +} + +/// Once a frame, before anything draws: each tape's steps whose moment has come. Returns at once +/// with nothing playing. +pub fn tick(self: *TapeTabs, editor: *Editor) void { + if (self.playing.items.len == 0) return; + const now = dvui.frameTimeNS(); + var i: usize = 0; + while (i < self.playing.items.len) { + const p = self.playing.items[i]; + const doc = editor.docFromPath(p.path); + if (doc == null) { + // Closed after it opened, or never came: the playback is over. Still loading: wait. + if (p.doc_id != null or now - p.opened_ns > load_limit_ns) { + free(editor, p); + _ = self.playing.swapRemove(i); + continue; + } + i += 1; + continue; + } + if (p.doc_id == null) { + p.doc_id = doc.?.id; + p.start_ns = now; + } + // Its panes come with its first draw. + if (p.note) |n| if (editor.noteOnDocument(doc.?.id, n)) { + editor.app.gpa.free(n); + p.note = null; + }; + if (p.trim) |t| switch (t.action) { + .none => preview(t, doc.?, now), + .cancel => { + // Nothing of it was written: the tab goes, and the playback with it next frame. + editor.rawCloseFileID(doc.?.id) catch {}; + t.action = .none; + }, + .share => { + t.action = .none; + share(editor, p, t, doc.?); + }, + }; + advance(editor, p, doc.?, now); + i += 1; + } +} + +/// Write the part kept as a tape in the config folder's `tapes/`, and end the trim: the tab is the +/// tape's from here, its transport back. +fn share(editor: *Editor, p: *Playback, t: *Trimming, doc: sdk.DocHandle) void { + const gpa = editor.app.gpa; + const bytes = trimmed(editor, p, t, doc, gpa) catch |err| { + dvui.log.warn("share: could not trim {s}: {t}", .{ p.path, err }); + _ = editor.noteOnDocument(doc.id, "Could not share"); + return; + }; + defer gpa.free(bytes); + const arena = dvui.currentWindow().arena(); + if (comptime @import("builtin").target.cpu.arch != .wasm32) { + const saved = editor.keepTape(arena, doc, bytes, "") catch |err| { + dvui.log.warn("share: could not keep the tape: {t}", .{err}); + _ = editor.noteOnDocument(doc.id, "Could not save the tape"); + return; + }; + _ = editor.noteOnDocument(doc.id, std.fmt.allocPrint(arena, "Saved {s}", .{std.fs.path.basename(saved)}) catch "Saved the tape"); + } + t.deinit(gpa); + gpa.destroy(t); + p.trim = null; + // The handles moved the document about; as it stands now is the tab's, so closing it asks + // nothing. + editor.writeDocument(doc) catch |err| dvui.log.warn("tape: could not keep {s}: {t}", .{ p.path, err }); +} + +/// Apply `p`'s steps due by `now`, and wake for the next. Played out, the document is written to +/// its own mount: it is as the tape left it, so closing it asks nothing (an edit of the person's +/// after that would). +fn advance(editor: *Editor, p: *Playback, doc: sdk.DocHandle, now: i128) void { + const ops = p.owned.tape.ops; + if (p.op == ops.len) return; + // To be trimmed, it plays in at once: the whole of it is there to trim. + const elapsed_ms: u64 = if (p.trim != null) std.math.maxInt(u64) else @intCast(@max(0, @divTrunc(now - p.start_ns, std.time.ns_per_ms))); + while (p.op < ops.len and ops[p.op].at <= elapsed_ms) : (p.op += 1) { + const op = ops[p.op]; + switch (op.do) { + .step => |st| if (!doc.owner.applyStep(doc, st.effect, st.motion, op.ms)) { + // The document is not what the step was made on (the person edited it): the rest + // would miss too, so the playback stops here, its document theirs. + p.op = ops.len; + _ = editor.noteOnDocument(doc.id, "Tape stopped: the document changed"); + return; + }, + else => {}, + } + } + if (p.op == ops.len) { + editor.writeDocument(doc) catch |err| dvui.log.warn("tape: could not keep {s} as played: {t}", .{ p.path, err }); + return; + } + const wait_ms: u64 = ops[p.op].at - elapsed_ms; + dvui.timer(dvui.Id.extendId(null, @src(), @intFromPtr(p)), @intCast(@min(wait_ms * 1000 + 1, std.math.maxInt(i32)))); +} + +fn trimming(gpa: std.mem.Allocator, tape: automation.Tape, authors: Authors) !*Trimming { + var n: usize = 0; + for (tape.ops) |op| n += @intFromBool(op.do == .step); + const t = try gpa.create(Trimming); + errdefer gpa.destroy(t); + const at = try gpa.alloc(u32, n); + errdefer gpa.free(at); + const ms = try gpa.alloc(u32, n); + errdefer gpa.free(ms); + var i: usize = 0; + for (tape.ops) |op| if (op.do == .step) { + at[i] = op.at; + ms[i] = op.ms; + i += 1; + }; + const by = try gpa.dupe(u8, if (authors.by.len == n) authors.by else &.{}); + errdefer gpa.free(by); + const names = try gpa.alloc([]u8, authors.names.len); + var made: usize = 0; + errdefer { + for (names[0..made]) |nm| gpa.free(nm); + gpa.free(names); + } + for (authors.names, names) |a, *nm| { + nm.* = try gpa.dupe(u8, a); + made += 1; + } + t.* = .{ .span = .all(n), .at = at, .ms = ms, .by = by, .names = names }; + return t; +} + +/// The tape at `path` being trimmed, and the summary its bar shows: how many steps the part kept +/// holds, how long it plays, whose they are. Null for a tape that only plays, or one still +/// playing in. +pub fn trimOf(self: *const TapeTabs, doc_id: u64, arena: std.mem.Allocator) ?struct { *Trimming, []const u8 } { + for (self.playing.items) |p| { + if (p.doc_id != doc_id) continue; + const t = p.trim orelse return null; + if (p.op < p.owned.tape.ops.len) return null; + return .{ t, summary(arena, t) }; + } + return null; +} + +fn summary(arena: std.mem.Allocator, t: *const Trimming) []const u8 { + const from = t.span.from; + const to = t.span.to; + if (to <= from) return ""; + const ms: u64 = t.at[to - 1] + t.ms[to - 1] - t.at[from]; + var out: std.Io.Writer.Allocating = .init(arena); + const w = &out.writer; + w.print("{d} step{s}, ", .{ to - from, if (to - from == 1) "" else "s" }) catch {}; + const secs = (ms + 500) / 1000; + if (secs < 60) w.print("{d} s", .{secs}) catch {} else w.print("{d} min {d} s", .{ secs / 60, secs % 60 }) catch {}; + if (t.by.len == t.at.len and t.names.len > 0) { + // Whose, each once, in the order they first come. + var seen: [8]u8 = undefined; + var count: usize = 0; + for (t.by[from..to]) |b| { + if (std.mem.indexOfScalar(u8, seen[0..count], b) != null) continue; + if (count == seen.len) break; + seen[count] = b; + count += 1; + } + w.writeAll(", by ") catch {}; + for (seen[0..count], 0..) |b, i| { + if (i > 0) w.writeAll(if (i == count - 1) " and " else ", ") catch {}; + w.writeAll(t.names[b]) catch {}; + } + } + return out.written(); +} + +/// The bar on a trimmed tape's pane, drawn by the pane in place of its transport; what was +/// pressed is done at the next frame's top (`tick`). +pub fn drawTrim(doc: sdk.DocHandle, t: *Trimming, text: []const u8, area: dvui.Rect.Physical, id_extra: usize) void { + const pressed = t.bar.draw(doc, area, id_extra, &t.span, text, t.playing_since != null); + if (pressed.grabbed) t.playing_since = null; + if (pressed.play) { + if (t.playing_since != null) t.playing_since = null else play(doc, t); + dvui.refresh(null, @src(), null); + } + if (pressed.cancel) t.action = .cancel; + if (pressed.share) t.action = .share; + if (t.action != .none) dvui.refresh(null, @src(), null); +} + +/// Start the preview from the playhead, or from the part's start when the playhead is outside it +/// or at its end. +fn play(doc: sdk.DocHandle, t: *Trimming) void { + const line = doc.owner.historyLine(doc, dvui.currentWindow().arena()) orelse return; + var head = line.at; + if (head < t.span.from or head >= t.span.to) { + head = t.span.from; + _ = doc.owner.historyGoTo(doc, if (head == 0) 0 else line.entries[head - 1].id); + } + const into: i128 = t.at[head] -| t.at[t.span.from]; + t.playing_since = dvui.frameTimeNS() - into * std.time.ns_per_ms; +} + +/// The preview's next frame: the document goes to the last step of the part whose moment has +/// come, and a timer wakes it for the one after. Ends at the part's end. +fn preview(t: *Trimming, doc: sdk.DocHandle, now: i128) void { + const since = t.playing_since orelse return; + const elapsed: i128 = @divTrunc(now - since, std.time.ns_per_ms); + const first = t.at[t.span.from]; + var stop = t.span.from; + while (stop < t.span.to and @as(i128, t.at[stop] - first) <= elapsed) stop += 1; + const line = doc.owner.historyLine(doc, dvui.currentWindow().arena()) orelse return; + if (line.entries.len != t.span.n) { + t.playing_since = null; + return; + } + if (stop != line.at) _ = doc.owner.historyGoTo(doc, if (stop == 0) 0 else line.entries[stop - 1].id); + if (stop >= t.span.to) { + t.playing_since = null; + return; + } + const wait_ms: i128 = @as(i128, t.at[stop] - first) - elapsed; + dvui.timer(dvui.Id.extendId(null, @src(), @intFromPtr(t)), @intCast(@min(@max(wait_ms, 0) * 1000 + 1, std.math.maxInt(i32)))); +} + +pub const TrimError = error{ NotTheTape, NoBytes, NotReturned, OutOfMemory }; + +/// The part of `p`'s tape that `t` keeps, as a tape of its own (binary, deflated, owned by `gpa`): +/// its keyframe the document's file where the part begins, written by its owner with the document +/// taken back there and brought forward again in the same call, unheard by its track and the +/// activity feed; then the part's steps, timed from its first. +fn trimmed(editor: *Editor, p: *Playback, t: *const Trimming, doc: sdk.DocHandle, gpa: std.mem.Allocator) TrimError![]u8 { + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const tape = p.owned.tape; + const file = tape.keyframes[0].files[0]; + const line = doc.owner.historyLine(doc, arena) orelse return error.NotTheTape; + if (line.entries.len != t.span.n) return error.NotTheTape; + const stopId = struct { + fn of(l: sdk.Plugin.HistoryLine, i: usize) u64 { + return if (i == 0) 0 else l.entries[i - 1].id; + } + }.of; + + const bytes = bytes: { + const host = &editor.app.host; + const tracks = if (editor.app.tracks) |*tr| tr else null; + const was_track_muted = if (tracks) |tr| tr.muted else 0; + if (tracks) |tr| tr.muted = doc.id; + defer if (tracks) |tr| { + tr.muted = was_track_muted; + }; + const was_muted = host.activity_muted; + host.activity_muted = true; + defer host.activity_muted = was_muted; + const now = line.at; + const moved = now != t.span.from; + if (moved and !doc.owner.historyGoTo(doc, stopId(line, t.span.from))) return error.NotTheTape; + const written = doc.owner.documentBytes(doc, file.path, arena) catch null; + if (moved and !doc.owner.historyGoTo(doc, stopId(line, now))) return error.NotReturned; + break :bytes written orelse return error.NoBytes; + }; + + const files = try arena.dupe(automation.Tape.File, tape.keyframes[0].files); + files[0].text = bytes; + const keyframes = try arena.dupe(automation.Tape.Keyframe, tape.keyframes); + keyframes[0].files = files; + const ops = try arena.alloc(automation.Tape.Op, t.span.to - t.span.from + 2); + ops[0] = .{ .at = 0, .do = .{ .keyframe = 0 } }; + ops[1] = .{ .at = 0, .do = .{ .wait = .{ .until = .idle } } }; + var i: usize = 0; + var out: usize = 2; + const first = t.at[t.span.from]; + for (tape.ops) |op| if (op.do == .step) { + if (i >= t.span.from and i < t.span.to) { + ops[out] = op; + ops[out].at = op.at - first; + out += 1; + } + i += 1; + }; + return automation.binary.encode(gpa, .{ .name = tape.name, .title = tape.title, .ops = ops, .keyframes = keyframes }, .{ .deflate = true }) catch error.OutOfMemory; +} + +fn openUnder(editor: *Editor, root: []const u8) bool { + for (editor.app.open_files.values()) |d| { + const path = d.owner.documentPath(d); + if (std.mem.startsWith(u8, path, root) and path.len > root.len and path[root.len] == '/') return true; + } + return false; +} + +/// Whether the document at `path` is a tape still to play out in its tab (loading, or steps to go). +pub fn isPlaying(self: *const TapeTabs, path: []const u8) bool { + for (self.playing.items) |p| if (std.mem.eql(u8, p.path, path) and p.op < p.owned.tape.ops.len) return true; + return false; +} + +fn free(editor: *Editor, p: *Playback) void { + const gpa = editor.app.gpa; + editor.app.host.unmount(p.root); + p.mem.deinit(); + gpa.destroy(p.mem); + gpa.free(p.root); + gpa.free(p.path); + if (p.note) |n| gpa.free(n); + if (p.trim) |t| { + t.deinit(gpa); + gpa.destroy(t); + } + p.owned.deinit(); + gpa.destroy(p); +} diff --git a/tests/integration.zig b/tests/integration.zig index 4a10b5c6b..70239359b 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -6785,6 +6785,85 @@ test "demo: fizzy's stage runs a command op's arguments through callCommand" { try std.testing.expectEqual(PaletteArgs.Side.right, got.side); } +// The control server's routes over a Host: the commands as the API, JSON in and out, errors as +// HTTP's (plans/CONTROL_SERVER_PLAN.md). The socket half is `app/control/Server.zig`'s own test. +const ControlFixture = struct { + fn place(_: *anyopaque, args: PaletteArgs.Params.Args, call: *sdk.Command.Call) anyerror!void { + if (args.line > 100) return call.fail("line {d} is past the end", .{args.line}); + try call.returns(.{ .line = args.line, .side = args.side, .column = args.column, .note = "placed" }); + } + fn never(_: *anyopaque) bool { + return false; + } + fn nothing(_: *anyopaque) anyerror!void {} +}; + +test "control: commands are listed, described and run over the routes" { + const gpa = std.testing.allocator; + const routes = @import("app").control.routes; + var host: sdk.Host = .init(gpa); + defer host.deinit(); + const vtable = sdk.Plugin.VTable{}; + var state: u8 = 0; + var plugin = sdk.Plugin{ .state = &state, .vtable = &vtable, .id = "t", .display_name = "T" }; + try host.registerCommand(.{ + .id = "t.place", + .owner = &plugin, + .title = "Place", + .params = PaletteArgs.Params.params, + .runWith = PaletteArgs.Params.bind(ControlFixture.place), + }); + try host.registerCommand(.{ .id = "t.off", .owner = &plugin, .title = "Off", .run = ControlFixture.nothing, .isEnabled = ControlFixture.never }); + + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // Who is answering. + const root = routes.handle(&host, .GET, "/", null, "", arena); + try std.testing.expectEqual(std.http.Status.ok, root.status); + try std.testing.expect(std.mem.indexOf(u8, root.body, "\"sdk_version\":") != null); + + // The listing: each command with its params, an enum's choices and a default. + const list = routes.handle(&host, .GET, "/commands", null, "", arena); + try std.testing.expectEqual(std.http.Status.ok, list.status); + const parsed = try std.json.parseFromSliceLeaky(std.json.Value, arena, list.body, .{}); + try std.testing.expectEqual(@as(usize, 2), parsed.array.items.len); + const place = parsed.array.items[0].object; + try std.testing.expectEqualStrings("t.place", place.get("id").?.string); + try std.testing.expectEqualStrings("t", place.get("owner").?.string); + try std.testing.expect(place.get("enabled").?.bool); + const params = place.get("params").?.array.items; + try std.testing.expectEqualStrings("line", params[0].object.get("key").?.string); + try std.testing.expect(params[0].object.get("required").?.bool); + try std.testing.expectEqualStrings("enum", params[1].object.get("kind").?.string); + try std.testing.expectEqualStrings("right", params[1].object.get("choices").?.array.items[1].string); + try std.testing.expectEqual(@as(i64, 1), params[2].object.get("default").?.integer); + try std.testing.expect(!parsed.array.items[1].object.get("enabled").?.bool); + + // Run it: JSON in (an enum as its name), the result back as JSON. + const ran = routes.handle(&host, .POST, "/commands/t.place", "application/json", "{\"line\":4,\"side\":\"right\"}", arena); + try std.testing.expectEqual(std.http.Status.ok, ran.status); + try std.testing.expectEqualStrings("{\"line\":4,\"side\":\"right\",\"column\":1,\"note\":\"placed\"}", ran.body); + + // ZON in, ZON out. + const zon = routes.handle(&host, .POST, "/commands/t.place", "application/zon", ".{ .line = 5, .side = .left }", arena); + try std.testing.expectEqual(std.http.Status.ok, zon.status); + try std.testing.expectEqualStrings("application/zon", zon.content_type); + try std.testing.expect(std.mem.indexOf(u8, zon.body, ".side = .left") != null or std.mem.indexOf(u8, zon.body, ".side=.left") != null); + + // Errors as HTTP's. + try std.testing.expectEqual(std.http.Status.not_found, routes.handle(&host, .POST, "/commands/t.missing", null, "", arena).status); + try std.testing.expectEqual(std.http.Status.not_found, routes.handle(&host, .GET, "/nowhere", null, "", arena).status); + try std.testing.expectEqual(std.http.Status.bad_request, routes.handle(&host, .POST, "/commands/t.place", null, "{\"side\":\"right\"}", arena).status); + try std.testing.expectEqual(std.http.Status.bad_request, routes.handle(&host, .POST, "/commands/t.place", null, "not json", arena).status); + try std.testing.expectEqual(std.http.Status.conflict, routes.handle(&host, .POST, "/commands/t.off", null, "", arena).status); + try std.testing.expectEqual(std.http.Status.method_not_allowed, routes.handle(&host, .DELETE, "/commands/t.place", null, "", arena).status); + const failed = routes.handle(&host, .POST, "/commands/t.place", null, "{\"line\":200,\"side\":\"left\"}", arena); + try std.testing.expectEqual(std.http.Status.internal_server_error, failed.status); + try std.testing.expectEqualStrings("{\"error\":\"line 200 is past the end\"}", failed.body); +} + test "a failed open is not a load in flight: settled and live tapes go on" { const gpa = std.testing.allocator; const Openings = @FieldType(fizzy.Editor, "openings"); @@ -7429,6 +7508,42 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expect(std.mem.indexOf(u8, host.callCommand("text.write", write_args, host.arena()).ok.?, ".changed = true") != null); try std.testing.expect(std.mem.indexOf(u8, host.callCommand("text.write", write_args, host.arena()).ok.?, ".changed = false") != null); + // `fizzy.undo`/`fizzy.redo` by path, on a document behind the one in front, as a script or + // an agent calls them: only the step `expect` names, and the answer says whose it was. + { + const notes_doc = editor.docFromPath(notes) orelse return error.TestUnexpectedResult; + const before = (try read(host, notes_args)).text; + const notes_write = try std.fmt.allocPrint(gpa, ".{{ .path = \"{s}\", .text = \"# Notes, by Claude\\n\" }}", .{notes}); + defer gpa.free(notes_write); + const outer = host.act(.{ .kind = .agent, .plugin = "test", .session = 1, .label = "Claude" }); + try std.testing.expect(host.callCommand("text.write", notes_write, host.arena()) == .ok); + host.endAct(outer); + const line = notes_doc.owner.historyLine(notes_doc, host.arena()) orelse return error.TestUnexpectedResult; + const top = line.entries[line.at - 1].id; + + const stale = try std.fmt.allocPrint(gpa, ".{{ .path = \"{s}\", .expect = {d} }}", .{ notes, top + 1000 }); + defer gpa.free(stale); + switch (host.callCommand("fizzy.undo", stale, host.arena())) { + .failed => |m| try std.testing.expect(std.mem.indexOf(u8, m, "by Claude") != null), + else => return error.TestUnexpectedResult, + } + try std.testing.expectEqualStrings("# Notes, by Claude\n", (try read(host, notes_args)).text); + + const named = try std.fmt.allocPrint(gpa, ".{{ .path = \"{s}\", .expect = {d} }}", .{ notes, top }); + defer gpa.free(named); + const undid = host.callCommand("fizzy.undo", named, host.arena()); + try std.testing.expect(undid == .ok); + try std.testing.expect(std.mem.indexOf(u8, undid.ok.?, ".by = \"Claude\"") != null); + try std.testing.expectEqualStrings(before, (try read(host, notes_args)).text); + // The document in front is untouched, and still in front. + try std.testing.expectEqualStrings(main_zig, (editor.activeDoc() orelse return error.TestUnexpectedResult).owner.documentPath(editor.activeDoc().?)); + + try std.testing.expect(host.callCommand("fizzy.redo", named, host.arena()) == .ok); + try std.testing.expectEqualStrings("# Notes, by Claude\n", (try read(host, notes_args)).text); + try std.testing.expect(host.callCommand("fizzy.undo", named, host.arena()) == .ok); + try std.testing.expectEqualStrings(before, (try read(host, notes_args)).text); + } + // Save and Close, the unsaved-changes dialog's: the same write Save makes, then the tab // closes once it lands. try std.testing.expect(doc.owner.isDirty(doc)); @@ -7912,6 +8027,250 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try dvui.testing.settle(headlessFrame); } + // A document's track as a tape (`fizzy.tracks.export`): its file as the track began, then its + // steps; the person's document and history as they were; played (`Demo.playTape`), it makes + // the same text in a document of its own. + { + const host = &editor.app.host; + const was = editor.activeDoc() orelse return error.TestUnexpectedResult; + editor.setTracking(true); + defer editor.setTracking(false); + try host.runCommand("fizzy.newFile"); + for (0..200) |_| { + _ = try dvui.testing.step(headlessFrame); + const d = editor.activeDoc() orelse continue; + if (d.id != was.id and std.mem.startsWith(u8, std.fs.path.basename(d.owner.documentPath(d)), "untitled")) break; + } + const doc = editor.activeDoc() orelse return error.TestUnexpectedResult; + try std.testing.expect(doc.id != was.id); + try dvui.testing.settle(headlessFrame); + for ([_][]const u8{ "one", "two", "three", "four", "five" }) |word| { + const args = try std.fmt.allocPrint(host.arena(), ".{{ .text = \"{s}\\n\", .line = 1 }}", .{word}); + try std.testing.expect(host.callCommand("text.insert", args, host.arena()) == .ok); + } + try editor.undo(); + try dvui.testing.settle(headlessFrame); + const textOf = struct { + fn textOf(h: *sdk.Host, path: []const u8) ![]const u8 { + const args = try std.fmt.allocPrint(h.arena(), ".{{ .path = \"{s}\" }}", .{path}); + const zon = switch (h.callCommand("text.read", args, h.arena())) { + .ok => |r| r orelse return error.TestUnexpectedResult, + else => return error.TestUnexpectedResult, + }; + const at = std.mem.indexOf(u8, zon, ".text = \"") orelse return error.TestUnexpectedResult; + const rest = zon[at + ".text = \"".len ..]; + return rest[0 .. std.mem.indexOfScalar(u8, rest, '"') orelse return error.TestUnexpectedResult]; + } + }.textOf; + const path = doc.owner.documentPath(doc); + const want = try gpa.dupe(u8, try textOf(host, path)); + defer gpa.free(want); + const line_before = (doc.owner.historyLine(doc, host.arena()) orelse return error.TestUnexpectedResult); + + const out = try std.fmt.allocPrint(gpa, "{s}/tapes/export-test.tape", .{editor.app.config_folder}); + defer gpa.free(out); + const args = try std.fmt.allocPrint(host.arena(), ".{{ .path = \"{s}\", .out = \"{s}\" }}", .{ path, out }); + const result = switch (host.callCommand("fizzy.tracks.export", args, host.arena())) { + .ok => |r| r orelse return error.TestUnexpectedResult, + else => |r| { + std.debug.print("export: {any}\n", .{r}); + return error.TestUnexpectedResult; + }, + }; + try std.testing.expect(std.mem.indexOf(u8, result, ".steps = 6") != null); + // A range (`from`): the tape opens on the document as it was after two steps, and holds + // the four after; its summary names whose they are. + { + const ranged = try std.fmt.allocPrint(gpa, "{s}/tapes/export-range.tape", .{editor.app.config_folder}); + defer gpa.free(ranged); + const range_args = try std.fmt.allocPrint(host.arena(), ".{{ .path = \"{s}\", .out = \"{s}\", .from = 2 }}", .{ path, ranged }); + const range_result = switch (host.callCommand("fizzy.tracks.export", range_args, host.arena())) { + .ok => |r| r orelse return error.TestUnexpectedResult, + else => return error.TestUnexpectedResult, + }; + try std.testing.expect(std.mem.indexOf(u8, range_result, ".steps = 4") != null); + // One author, named (a command run with no one acting is nobody known). + try std.testing.expect(std.mem.indexOf(u8, range_result, ".authors = .{\"unattributed\"}") != null); + const range_bytes = try std.Io.Dir.cwd().readFileAlloc(dvui.io, ranged, gpa, .limited(1 << 20)); + defer gpa.free(range_bytes); + var range_check = app.automation.Input.check; + range_check.steps = fizzy.Editor.Demo.stepsCheck; + var range_tape = try app.automation.Tape.load(gpa, range_bytes, range_check); + defer range_tape.deinit(); + try std.testing.expectEqualStrings("two\none\n", range_tape.tape.keyframes[0].files[0].text); + var range_steps: usize = 0; + for (range_tape.tape.ops) |op| range_steps += @intFromBool(op.do == .step); + try std.testing.expectEqual(@as(usize, 4), range_steps); + // The person's document is where it was. + try std.testing.expectEqualStrings(want, try textOf(host, path)); + } + // The person's document is where it was, its history too. + try std.testing.expectEqualStrings(want, try textOf(host, path)); + const line_after = (doc.owner.historyLine(doc, host.arena()) orelse return error.TestUnexpectedResult); + try std.testing.expectEqual(line_before.at, line_after.at); + try std.testing.expectEqual(line_before.entries.len, line_after.entries.len); + + const bytes = try std.Io.Dir.cwd().readFileAlloc(dvui.io, out, gpa, .limited(1 << 20)); + defer gpa.free(bytes); + // What it holds: fizzy reads it (its owner and format known), its keyframe is the file as + // the track began (a new document's: empty). + var check = app.automation.Input.check; + check.steps = fizzy.Editor.Demo.stepsCheck; + var owned = try app.automation.Tape.load(gpa, bytes, check); + defer owned.deinit(); + const kf = owned.tape.keyframes[0]; + try std.testing.expectEqual(@as(usize, 1), kf.files.len); + try std.testing.expectEqualStrings("text", kf.files[0].owner); + try std.testing.expectEqualStrings("", kf.files[0].text); + // Played in a tab of its own (`TapeTabs`, the transport's share), each step at its moment: + // the same text, under the tape's own mount, with the person's documents untouched. + try editor.tape_tabs.open(editor, bytes, .{}); + const played_path = try std.fmt.allocPrint(gpa, "tape://1/{s}", .{std.fs.path.basename(path)}); + defer gpa.free(played_path); + for (0..2000) |_| { + _ = try dvui.testing.step(headlessFrame); + if (!editor.tape_tabs.isPlaying(played_path)) break; + std.Io.sleep(dvui.io, .fromMilliseconds(5), .awake) catch {}; + } + const replay = editor.docFromPath(played_path) orelse return error.TestUnexpectedResult; + try std.testing.expect(!editor.tape_tabs.isPlaying(played_path)); + try dvui.testing.settle(headlessFrame); + try std.testing.expectEqualStrings(want, try textOf(host, played_path)); + try std.testing.expectEqualStrings(want, try textOf(host, path)); + const played_line = replay.owner.historyLine(replay, host.arena()) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(usize, 6), played_line.at); + // Played out, it is kept as played in its own mount: closing it asks nothing. + for (0..200) |_| { + if (!replay.owner.isDirty(replay)) break; + _ = try dvui.testing.step(headlessFrame); + } + try std.testing.expect(!replay.owner.isDirty(replay)); + try editor.closeFileID(replay.id); + try dvui.testing.settle(headlessFrame); + try std.testing.expect(editor.docFromPath(played_path) == null); + try std.testing.expectEqual(@as(usize, 0), editor.tape_tabs.playing.items.len); + + // The transport's share (pressed: `share_pending`), at the top of the next frame: the + // whole track opened in a tab to trim, played in at once, nothing written yet. Trimmed + // to the part after two steps and shared, the tape kept holds that part: the document as + // it was after two steps, then the four after. Cancelled, nothing is written and the tab + // goes. + const tapes_dir = try std.fs.path.join(gpa, &.{ editor.app.config_folder, "tapes" }); + defer gpa.free(tapes_dir); + const newest = struct { + /// How many tapes there are, and the newest name not in `before` (owned by `a`). + fn newest(a: std.mem.Allocator, dir_path: []const u8, before: []const []const u8) !struct { usize, ?[]const u8 } { + var dir = try std.Io.Dir.cwd().openDir(dvui.io, dir_path, .{ .iterate = true }); + defer dir.close(dvui.io); + var it = dir.iterate(); + var n: usize = 0; + var fresh: ?[]const u8 = null; + while (try it.next(dvui.io)) |e| { + n += 1; + for (before) |b| { + if (std.mem.eql(u8, b, e.name)) break; + } else fresh = try a.dupe(u8, e.name); + } + return .{ n, fresh }; + } + }.newest; + var keep_state: std.heap.ArenaAllocator = .init(gpa); + defer keep_state.deinit(); + const keep = keep_state.allocator(); + var names: std.ArrayListUnmanaged([]const u8) = .empty; + { + var dir = try std.Io.Dir.cwd().openDir(dvui.io, tapes_dir, .{ .iterate = true }); + defer dir.close(dvui.io); + var it = dir.iterate(); + while (try it.next(dvui.io)) |e| try names.append(keep, try keep.dupe(u8, e.name)); + } + const kept_before = names.items.len; + const openTrim = struct { + fn open(ed: *fizzy.Editor, id: u64, at_path: []const u8) !sdk.DocHandle { + ed.share_pending = id; + for (0..400) |_| { + _ = try dvui.testing.step(headlessFrame); + if (ed.docFromPath(at_path)) |d| if (ed.tape_tabs.trimOf(d.id, dvui.currentWindow().arena()) != null) return d; + } + return error.TestUnexpectedResult; + } + }.open; + const shared_path = try std.fmt.allocPrint(gpa, "tape://2/{s}", .{std.fs.path.basename(path)}); + defer gpa.free(shared_path); + const shared = try openTrim(editor, doc.id, shared_path); + try std.testing.expectEqual(kept_before, (try newest(keep, tapes_dir, names.items))[0]); + try dvui.testing.settle(headlessFrame); + try std.testing.expectEqualStrings(want, try textOf(host, shared_path)); + { + const trim, const said = editor.tape_tabs.trimOf(shared.id, host.arena()).?; + try std.testing.expectEqual(@as(usize, 6), trim.span.n); + try std.testing.expect(std.mem.startsWith(u8, said, "6 steps")); + trim.span.from = 2; + trim.action = .share; + } + for (0..20) |_| _ = try dvui.testing.step(headlessFrame); + try std.testing.expect(editor.tape_tabs.trimOf(shared.id, host.arena()) == null); + const kept_after, const fresh = try newest(keep, tapes_dir, names.items); + try std.testing.expectEqual(kept_before + 1, kept_after); + { + const kept_path = try std.fs.path.join(keep, &.{ tapes_dir, fresh.? }); + const kept = try std.Io.Dir.cwd().readFileAlloc(dvui.io, kept_path, gpa, .limited(1 << 20)); + defer gpa.free(kept); + var kept_check = app.automation.Input.check; + kept_check.steps = fizzy.Editor.Demo.stepsCheck; + var kept_tape = try app.automation.Tape.load(gpa, kept, kept_check); + defer kept_tape.deinit(); + try std.testing.expectEqualStrings("two\none\n", kept_tape.tape.keyframes[0].files[0].text); + var kept_steps: usize = 0; + for (kept_tape.tape.ops) |op| kept_steps += @intFromBool(op.do == .step); + try std.testing.expectEqual(@as(usize, 4), kept_steps); + } + // The tab and the person's document are where they were. + try std.testing.expectEqualStrings(want, try textOf(host, shared_path)); + try std.testing.expectEqualStrings(want, try textOf(host, path)); + try editor.rawCloseFileID(shared.id); + try dvui.testing.settle(headlessFrame); + + const cancelled_path = try std.fmt.allocPrint(gpa, "tape://3/{s}", .{std.fs.path.basename(path)}); + defer gpa.free(cancelled_path); + const cancelled = try openTrim(editor, doc.id, cancelled_path); + editor.tape_tabs.trimOf(cancelled.id, host.arena()).?[0].action = .cancel; + try dvui.testing.settle(headlessFrame); + try std.testing.expect(editor.docFromPath(cancelled_path) == null); + try std.testing.expectEqual(kept_after, (try newest(keep, tapes_dir, names.items))[0]); + + try editor.rawCloseFileID(doc.id); + + // A track whose start is gone from the history (undone past, and edited over) is + // refused, and the document is left as it was. + editor.setTracking(false); + try host.runCommand("fizzy.newFile"); + for (0..200) |_| { + _ = try dvui.testing.step(headlessFrame); + const d = editor.activeDoc() orelse continue; + if (d.id != doc.id and d.id != was.id and std.mem.startsWith(u8, std.fs.path.basename(d.owner.documentPath(d)), "untitled")) break; + } + const gone = editor.activeDoc() orelse return error.TestUnexpectedResult; + for ([_][]const u8{ "a", "b" }) |word| { + const insert = try std.fmt.allocPrint(host.arena(), ".{{ .text = \"{s}\\n\", .line = 1 }}", .{word}); + try std.testing.expect(host.callCommand("text.insert", insert, host.arena()) == .ok); + } + editor.setTracking(true); + try dvui.testing.settle(headlessFrame); + try editor.undo(); + try editor.undo(); + try std.testing.expect(host.callCommand("text.insert", ".{ .text = \"c\\n\", .line = 1 }", host.arena()) == .ok); + const gone_path = gone.owner.documentPath(gone); + const before_text = try gpa.dupe(u8, try textOf(host, gone_path)); + defer gpa.free(before_text); + const gone_args = try std.fmt.allocPrint(host.arena(), ".{{ .path = \"{s}\", .out = \"{s}\" }}", .{ gone_path, out }); + try std.testing.expect(host.callCommand("fizzy.tracks.export", gone_args, host.arena()) != .ok); + try std.testing.expectEqualStrings(before_text, try textOf(host, gone_path)); + try editor.rawCloseFileID(gone.id); + editor.workbench.setActiveDocIndex(editor.app.open_files.getIndex(was.id) orelse return error.TestUnexpectedResult); + try dvui.testing.settle(headlessFrame); + } + // Revealing a view (`Host.revealSurface`): with the bottom panel shut, revealing Output opens // the panel with Output selected, and a view no region shows is not revealed. {