From 33530ae7aae0aa3d89490a7403aac18fa566811f Mon Sep 17 00:00:00 2001 From: foxnne Date: Sat, 17 Oct 2026 10:52:37 -0500 Subject: [PATCH] app: fizzy.undo and fizzy.redo take a path and the step expected `fizzy.undo .{ .path, .expect }` undoes one step of any open document, not only the active one, over its owner's `historyLine` and `historyGoTo`. With `expect`, it takes only that step, and refuses (naming the step on top and whose it is) when another has been made since, so a caller never takes back someone else's change. It answers the step, its title and whose it was. Keys, menus and the palette run Undo as before. Co-Authored-By: Claude Opus 5.5 --- sdk/src/Plugin.zig | 2 ++ src/editor/Keybinds.zig | 68 ++++++++++++++++++++++++++++++++++++----- tests/integration.zig | 36 ++++++++++++++++++++++ 3 files changed, 98 insertions(+), 8 deletions(-) diff --git a/sdk/src/Plugin.zig b/sdk/src/Plugin.zig index e96f6b007..a0c87a807 100644 --- a/sdk/src/Plugin.zig +++ b/sdk/src/Plugin.zig @@ -236,6 +236,8 @@ pub const VTable = struct { historyLine: ?*const fn (state: *anyopaque, doc: DocHandle, arena: std.mem.Allocator) anyerror!HistoryLine = null, /// Undo or redo `doc` until step `id` (`HistoryLine.Entry.id`) is the last one applied; 0 /// goes back before the first. An id not on the line is an error, and nothing moves. + /// `fizzy.undo`/`fizzy.redo` with a `path` or an `expect` go through it too, so a document + /// can be undone without being the active one. historyGoTo: ?*const fn (state: *anyopaque, doc: DocHandle, id: u64) anyerror!void = null, /// Apply a step this owner recorded (`Step`, `Host.recordStep`) to `doc`, forward, as a new /// entry on its history: `effect` and `motion` in the owner's `step_format`. `ms` asks for it diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index e742b485d..0df9d1bfb 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" }, @@ -412,6 +412,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 +552,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 diff --git a/tests/integration.zig b/tests/integration.zig index 17c65ba61..968574a36 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7418,6 +7418,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));