Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions sdk/src/Plugin.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 60 additions & 8 deletions src/editor/Keybinds.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions tests/integration.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading