Skip to content

Commit ff12fab

Browse files
foxnneclaude
andauthored
app: Undo names whose step it takes, and the bubble after it has a Redo (#495)
Part of fizzyedit/plans#14 — `plans/SHARED_HISTORY_PLAN.md` step 5b (after 5a, #487). ## What changes Cmd-Z stays linear: it takes the last change, whoever made it. It now says whose that is. - **The Edit menu names it.** When the step Undo would take was made by someone else, the row reads **"Undo Claude's Draw"**, and Redo likewise. The person's own steps read "Undo", so working alone looks as it always has. The title is dynamic, read when the menu opens; it reads the owner's `historyLine` only once someone besides the person has acted on the document (`Host.step_authors`). - **The bubble after it has a Redo.** Pressing Undo (key or menu) on someone else's step shows **"Undid Claude's Edit"** on the document's pane, in place of the plain "Undo" its run would show, with a **Redo** that puts the step back. Redo acts only while that step is still next to redo. - **`ActivityFeed.pushAction`.** A feed item may carry a button. It alone takes clicks, on a small layer of its own, so the rest of the feed still never covers the work. An item with a button stays 6 s instead of 2.4 s. `draw` returns the tag of the button pressed. - **`Editor.stepToTake`** finds the step an undo or redo would take, where it leaves the line, and its author. It is shared by the menu title, the bubble and `fizzy.undo`'s `expect` (which #487 had inline). Muting the run's own "Undo" reuses `Host.activity_muted` (the demo-seek flag) for the duration of that one command. It is put back before the feed draws, and the code says so. ## SDK impact - [x] None (the fingerprint does not move; `test-sdk-version` passes). ## Verified - **Integration** (`headless: the whole editor comes up…`): an author labelled "Claude" writes into the document in front. - `stepToTake` names Claude. - `editor.undo()` (Cmd-Z's path) restores the text, and the pane's last feed item reads "Undid Claude's …" with a Redo tagged with that step. - Pressing it brings the text back. - **Seen on macOS** in a sandbox fizzy built from this branch: an MCP client named `claude-code` wrote into an open `note.txt`, then Cmd-Z was pressed through the input tape. - The pane showed **"Undid Claude's Edit · Redo"** above the history transport. - Clicking Redo at its spot brought Claude's line back (`text.read`: 3 lines again). - The Edit menu's title was not looked at on screen; it is the same lookup the test checks. - Gates on macOS: `check-fmt`, `zig build test` (574/575, 1 skipped), `zig build test-integration` (53/53 steps), `check-web`, `test-sdk-version`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c8bbbe9 commit ff12fab

5 files changed

Lines changed: 181 additions & 41 deletions

File tree

‎app/ActivityFeed.zig‎

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
//! The commands just run on one document (`Host.activity`), shown small at the bottom right of
22
//! its pane as they run: each fades in, stays a moment and fades out, the older ones rising as
33
//! newer ones come. Drawn on a layer that takes no input, so it never covers the work, and on wall
4-
//! time, so whatever runs the commands (a person, an agent, a tape) shows the same.
4+
//! time, so whatever runs the commands (a person, an agent, a tape) shows the same. One that
5+
//! offers an action ("Undid Claude's Draw" with its Redo) takes clicks on its button only, and
6+
//! stays longer to be reached.
57
const ActivityFeed = @This();
68

79
const std = @import("std");
@@ -16,6 +18,8 @@ seen: u64 = 0,
1618

1719
const max = 5;
1820
const life_ns: i128 = 2400 * std.time.ns_per_ms;
21+
/// An item with an action stays this long.
22+
const action_life_ns: i128 = 6000 * std.time.ns_per_ms;
1923
const fade_ns: i128 = 250 * std.time.ns_per_ms;
2024
const pad: f32 = 8;
2125
const gap: f32 = 4;
@@ -24,6 +28,19 @@ const Item = struct {
2428
title: [48]u8,
2529
len: u8,
2630
born: i128,
31+
action: ?Action = null,
32+
33+
fn life(self: Item) i128 {
34+
return if (self.action != null) action_life_ns else life_ns;
35+
}
36+
};
37+
38+
/// A button on an item: its label, and the caller's own tag for it, which `draw` answers when
39+
/// it is pressed.
40+
const Action = struct {
41+
label: [16]u8,
42+
len: u8,
43+
tag: u64,
2744
};
2845

2946
/// A feed showing only what runs from now on.
@@ -57,14 +74,26 @@ pub fn push(self: *ActivityFeed, title: []const u8, now: i128) void {
5774
self.len += 1;
5875
}
5976

60-
/// Draw the feed in the bottom right of `area` (physical), newest lowest.
61-
pub fn draw(self: *ActivityFeed, area: dvui.Rect.Physical, id_extra: usize, now: i128) void {
62-
// The oldest go first.
63-
while (self.len > 0 and now - self.items[0].born > life_ns) {
64-
std.mem.copyForwards(Item, self.items[0 .. self.len - 1], self.items[1..self.len]);
65-
self.len -= 1;
77+
/// `push`, with a button labelled `label` on it: `draw` answers `tag` when it is pressed.
78+
pub fn pushAction(self: *ActivityFeed, title: []const u8, label: []const u8, tag: u64, now: i128) void {
79+
self.push(title, now);
80+
var action: Action = .{ .label = undefined, .len = @intCast(@min(label.len, 16)), .tag = tag };
81+
@memcpy(action.label[0..action.len], label[0..action.len]);
82+
self.items[self.len - 1].action = action;
83+
}
84+
85+
/// Draw the feed in the bottom right of `area` (physical), newest lowest. Answers the tag of the
86+
/// action pressed this frame, if one was.
87+
pub fn draw(self: *ActivityFeed, area: dvui.Rect.Physical, id_extra: usize, now: i128) ?u64 {
88+
// Those whose time is up go.
89+
var kept: usize = 0;
90+
for (self.items[0..self.len]) |item| {
91+
if (now - item.born > item.life()) continue;
92+
self.items[kept] = item;
93+
kept += 1;
6694
}
67-
if (self.len == 0) return;
95+
self.len = kept;
96+
if (self.len == 0) return null;
6897
dvui.refresh(null, @src(), null);
6998

7099
const r = area.toNatural();
@@ -89,9 +118,10 @@ pub fn draw(self: *ActivityFeed, area: dvui.Rect.Physical, id_extra: usize, now:
89118
const age = now - item.born;
90119
// In, then out, over `fade_ns` each; rising into place as it comes in.
91120
const in = @min(1, @as(f32, @floatFromInt(age)) / @as(f32, @floatFromInt(fade_ns)));
92-
const out = @min(1, @as(f32, @floatFromInt(life_ns - age)) / @as(f32, @floatFromInt(fade_ns)));
121+
const out = @min(1, @as(f32, @floatFromInt(item.life() - age)) / @as(f32, @floatFromInt(fade_ns)));
93122
const alpha = @max(0, @min(in, out));
94-
const w = font.textSize(item.title[0..item.len]).w + pad * 2;
123+
const action_w = if (item.action) |a| font.textSize(a.label[0..a.len]).w + pad * 2 else 0;
124+
const w = font.textSize(item.title[0..item.len]).w + pad * 2 + action_w;
95125
const rect: dvui.Rect = .{ .x = r.w - 12 - w, .y = bottom - h + (1 - in) * h * 0.5, .w = w, .h = h };
96126
// Glass has no fade: it grows in from, and shrinks out to, a little smaller.
97127
const k = 0.6 + 0.4 * alpha;
@@ -102,6 +132,7 @@ pub fn draw(self: *ActivityFeed, area: dvui.Rect.Physical, id_extra: usize, now:
102132
bottom -= (h + gap) * in;
103133
}
104134
core.dialogs.glassBubbles(layer.data().id, bubbles[0..self.len], rs.s, .{});
135+
var pressed: ?u64 = null;
105136
for (self.items[0..self.len], rects[0..self.len], alphas[0..self.len], 0..) |item, rect, alpha, n| {
106137
dvui.labelNoFmt(@src(), item.title[0..item.len], .{}, .{
107138
.rect = rect,
@@ -111,5 +142,21 @@ pub fn draw(self: *ActivityFeed, area: dvui.Rect.Physical, id_extra: usize, now:
111142
.margin = .{},
112143
.color_text = .{ .color = ink.opacity(0.85 * alpha) },
113144
});
145+
const a = item.action orelse continue;
146+
// The button alone takes input, on a layer of its own over the feed's.
147+
const bw = font.textSize(a.label[0..a.len]).w + pad * 2;
148+
var hit: dvui.FloatingWidget = undefined;
149+
hit.init(@src(), .{}, .{ .rect = .{ .x = r.x + rect.x + rect.w - bw, .y = r.y + rect.y, .w = bw, .h = rect.h }, .id_extra = id_extra *% 8 +% n });
150+
defer hit.deinit();
151+
if (dvui.button(@src(), a.label[0..a.len], .{}, .{
152+
.expand = .both,
153+
.font = font,
154+
.padding = .{ .x = pad, .y = pad / 2, .w = pad, .h = pad / 2 },
155+
.margin = .{},
156+
.background = false,
157+
.border = .{},
158+
.color_text = .{ .color = dvui.themeGet().color(.highlight, .fill).opacity(alpha) },
159+
})) pressed = a.tag;
114160
}
161+
return pressed;
115162
}

‎src/editor/Editor.zig‎

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2978,7 +2978,7 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result {
29782978
if (claim_pill == null) if (ds.transport.place(area, now)) |bar| {
29792979
area.h = @min(area.h, bar.y - area.y + 6 * dvui.windowRectScale().s);
29802980
};
2981-
ds.feed.draw(area, @truncate(ds.doc_id), now);
2981+
if (ds.feed.draw(area, @truncate(ds.doc_id), now)) |step| editor.redoFromFeed(doc, step);
29822982
}
29832983

29842984
// A menu inside the document only when its owner names one. The context is created *after*
@@ -6189,19 +6189,74 @@ pub fn redo(editor: *Editor) !void {
61896189

61906190
/// An undo or a redo of the active document, through its owner's command: its transport shows,
61916191
/// and a key held down goes further each press (`HistoryTransport.press`), the command run that
6192-
/// many times. Nothing to undo, or a claimed document: the command as ever, and no bar.
6193-
fn historyStep(editor: *Editor, action: enum { undo, redo }) !void {
6192+
/// many times. Nothing to undo, or a claimed document: the command as ever, and no bar. An undo of
6193+
/// a step someone else made says so on the document, with a Redo (`ActivityFeed.pushAction`), in
6194+
/// place of the plain "Undo" its run would show.
6195+
fn historyStep(editor: *Editor, action: HistoryAction) !void {
61946196
const doc = editor.activeDoc() orelse return;
61956197
const name = @tagName(action);
6196-
const shows = editor.app.host.documentClaim(doc.id) == null and editor.activeDocCommandEnabled(name);
6198+
const host = &editor.app.host;
6199+
const shows = host.documentClaim(doc.id) == null and editor.activeDocCommandEnabled(name);
61976200
const ds = if (shows) editor.doc_surfaces.get(doc.id) else null;
61986201
const by = if (ds) |d| d.transport.press(dvui.frameTimeNS(), action == .redo) else 1;
61996202
for (0..by) |_| {
62006203
if (!editor.activeDocCommandEnabled(name)) break;
6204+
// Who made a step is kept only once someone besides the person has acted on it.
6205+
const others: ?StepToTake = if (action == .undo and host.step_authors.contains(doc.id)) blk: {
6206+
const t = editor.stepToTake(doc, .undo, host.arena()) catch break :blk null;
6207+
break :blk if (t.by.kind == .person) null else t;
6208+
} else null;
6209+
const t = others orelse {
6210+
try editor.runActiveDocCommand(name);
6211+
continue;
6212+
};
6213+
// The run's own "Undo" goes unrecorded (as while a demo seeks), so the bubble below is
6214+
// the one it shows. The flag is put back before the feed draws.
6215+
const muted = host.activity_muted;
6216+
host.activity_muted = true;
6217+
defer host.activity_muted = muted;
62016218
try editor.runActiveDocCommand(name);
6219+
var buf: [96]u8 = undefined;
6220+
const said = std.fmt.bufPrint(&buf, "Undid {s}'s {s}", .{ authorName(t.by), t.step.title }) catch "Undid a change";
6221+
if (editor.doc_surfaces.get(doc.id)) |d| d.feed.pushAction(said, "Redo", t.step.id, dvui.frameTimeNS());
62026222
}
62036223
}
62046224

6225+
/// Redo `doc`'s step `id` from its bubble's Redo: only while it is still the next one to redo.
6226+
pub fn redoFromFeed(editor: *Editor, doc: sdk.DocHandle, id: u64) void {
6227+
const t = editor.stepToTake(doc, .redo, editor.app.host.arena()) catch return;
6228+
if (t.step.id == id) _ = doc.owner.historyGoTo(doc, t.to);
6229+
}
6230+
6231+
pub const HistoryAction = enum { undo, redo };
6232+
6233+
/// The step an undo or a redo of a document would take, the step it leaves on top (`to`, for
6234+
/// `historyGoTo`; 0 before the first), and who made it.
6235+
pub const StepToTake = struct { step: sdk.Plugin.HistoryLine.Entry, to: u64, by: sdk.Author };
6236+
6237+
/// What an undo or a redo of `doc` would take, from its owner's line (`historyLine`):
6238+
/// `error.NoLine` when the owner keeps none, `error.Nothing` when there is nothing to take.
6239+
pub fn stepToTake(editor: *Editor, doc: sdk.DocHandle, action: HistoryAction, arena: std.mem.Allocator) error{ NoLine, Nothing }!StepToTake {
6240+
const line = doc.owner.historyLine(doc, arena) orelse return error.NoLine;
6241+
const step: sdk.Plugin.HistoryLine.Entry, const to: u64 = switch (action) {
6242+
.undo => blk: {
6243+
if (line.at == 0) return error.Nothing;
6244+
break :blk .{ line.entries[line.at - 1], if (line.at >= 2) line.entries[line.at - 2].id else 0 };
6245+
},
6246+
.redo => blk: {
6247+
if (line.at >= line.entries.len) return error.Nothing;
6248+
break :blk .{ line.entries[line.at], line.entries[line.at].id };
6249+
},
6250+
};
6251+
return .{ .step = step, .to = to, .by = editor.app.host.stepAuthor(doc.id, step.id) };
6252+
}
6253+
6254+
/// Who made a step, as a person reads it: "the person", else the author's label ("Claude").
6255+
pub fn authorName(a: sdk.Author) []const u8 {
6256+
if (a.kind == .person) return "the person";
6257+
return if (a.label.len > 0) a.label else @tagName(a.kind);
6258+
}
6259+
62056260
pub fn openInFileBrowser(editor: *Editor, path: []const u8) !void {
62066261
// Darwin goes through `darwin_spawn` rather than `std.process.run`: the latter walks the
62076262
// `environ` array captured at startup, which any `unsetenv` elsewhere in the process (SDL,

‎src/editor/Keybinds.zig‎

Lines changed: 16 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -442,40 +442,31 @@ fn cmdRedoWith(state: *anyopaque, args: HistoryStep.Args, call: *sdk.Command.Cal
442442
/// `historyGoTo`), so it reaches a document that is not active. With `expect`, only when that
443443
/// step is the one it would take, so a caller never takes back a step made since it looked
444444
/// (`plans/SHARED_HISTORY_PLAN.md`, step 5). Answers the step and whose it was.
445-
fn historyStepWith(editor: *Editor, args: HistoryStep.Args, call: *sdk.Command.Call, action: enum { undo, redo }) !void {
445+
fn historyStepWith(editor: *Editor, args: HistoryStep.Args, call: *sdk.Command.Call, action: Editor.HistoryAction) !void {
446446
const doc = if (args.path.len == 0)
447447
editor.activeDoc() orelse return call.fail("no document is active", .{})
448448
else
449449
editor.docFromPath(args.path) orelse return call.fail("no open document at {s}", .{args.path});
450-
const line = doc.owner.historyLine(doc, call.arena) orelse {
450+
const t = editor.stepToTake(doc, action, call.arena) catch |err| switch (err) {
451+
error.Nothing => return call.fail("nothing to {s}", .{@tagName(action)}),
451452
// An owner with no line undoes the active document only, through its own command.
452-
const active = editor.activeDoc();
453-
if (args.expect != 0 or active == null or active.?.id != doc.id)
454-
return call.fail("{s} keeps no history line, so it can only undo the active document, with no expect", .{doc.owner.id});
455-
return switch (action) {
456-
.undo => editor.undo(),
457-
.redo => editor.redo(),
458-
};
459-
};
460-
const host = &editor.app.host;
461-
const step: sdk.Plugin.HistoryLine.Entry, const to: u64 = switch (action) {
462-
.undo => blk: {
463-
if (line.at == 0) return call.fail("nothing to undo", .{});
464-
break :blk .{ line.entries[line.at - 1], if (line.at >= 2) line.entries[line.at - 2].id else 0 };
465-
},
466-
.redo => blk: {
467-
if (line.at >= line.entries.len) return call.fail("nothing to redo", .{});
468-
break :blk .{ line.entries[line.at], line.entries[line.at].id };
453+
error.NoLine => {
454+
const active = editor.activeDoc();
455+
if (args.expect != 0 or active == null or active.?.id != doc.id)
456+
return call.fail("{s} keeps no history line, so it can only {s} the active document, with no expect", .{ doc.owner.id, @tagName(action) });
457+
return switch (action) {
458+
.undo => editor.undo(),
459+
.redo => editor.redo(),
460+
};
469461
},
470462
};
471-
const whose = host.stepAuthor(doc.id, step.id);
472-
const by = if (whose.kind == .person) "the person" else if (whose.label.len > 0) whose.label else @tagName(whose.kind);
473-
if (args.expect != 0 and args.expect != step.id) return call.fail(
463+
const by = Editor.authorName(t.by);
464+
if (args.expect != 0 and args.expect != t.step.id) return call.fail(
474465
"step {d} is not the one to {s}: that is step {d}, {s}, by {s}. The document changed since; fix it forward",
475-
.{ args.expect, @tagName(action), step.id, step.title, by },
466+
.{ args.expect, @tagName(action), t.step.id, t.step.title, by },
476467
);
477-
if (!doc.owner.historyGoTo(doc, to)) return call.fail("{s} could not {s} step {d}", .{ doc.owner.id, @tagName(action), step.id });
478-
try call.returns(.{ .step = step.id, .title = step.title, .by = by });
468+
if (!doc.owner.historyGoTo(doc, t.to)) return call.fail("{s} could not {s} step {d}", .{ doc.owner.id, @tagName(action), t.step.id });
469+
try call.returns(.{ .step = t.step.id, .title = t.step.title, .by = by });
479470
}
480471
/// Set while `tick` is running a command because of a live key event. That event is never
481472
/// marked handled, so it goes on to reach the focused widget under its own steam — a clipboard

‎src/editor/menu_model.zig‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,27 @@ fn canSaveAll(editor: *Editor) bool {
112112
return false;
113113
}
114114

115+
/// "Undo Claude's Draw" when the step it would take is someone else's; "Undo" for the person's
116+
/// own, so working alone reads as it always has.
117+
fn undoTitle(editor: *Editor) [:0]const u8 {
118+
return historyTitle(editor, .undo, "Undo", &undo_title);
119+
}
120+
fn redoTitle(editor: *Editor) [:0]const u8 {
121+
return historyTitle(editor, .redo, "Redo", &redo_title);
122+
}
123+
124+
var undo_title: [96:0]u8 = undefined;
125+
var redo_title: [96:0]u8 = undefined;
126+
127+
fn historyTitle(editor: *Editor, action: Editor.HistoryAction, plain: [:0]const u8, buf: *[96:0]u8) [:0]const u8 {
128+
const doc = editor.activeDoc() orelse return plain;
129+
// Who made a step is kept only once someone besides the person has acted on the document.
130+
if (!editor.app.host.step_authors.contains(doc.id)) return plain;
131+
const t = editor.stepToTake(doc, action, editor.app.host.arena()) catch return plain;
132+
if (t.by.kind == .person) return plain;
133+
return std.fmt.bufPrintZ(buf, "{s} {s}'s {s}", .{ plain, Editor.authorName(t.by), t.step.title }) catch plain;
134+
}
135+
115136
fn canUndo(editor: *Editor) bool {
116137
return editor.activeDocCommandEnabled("undo");
117138
}
@@ -193,8 +214,8 @@ const edit_items = [_]Item{
193214
.native_always_enabled = true,
194215
} },
195216
.separator,
196-
.{ .command = .{ .id = "fizzy.undo", .title = .{ .static = "Undo" }, .sf_symbol = "arrow.uturn.backward", .enabled = canUndo } },
197-
.{ .command = .{ .id = "fizzy.redo", .title = .{ .static = "Redo" }, .sf_symbol = "arrow.uturn.forward", .enabled = canRedo } },
217+
.{ .command = .{ .id = "fizzy.undo", .title = .{ .dynamic = undoTitle }, .sf_symbol = "arrow.uturn.backward", .enabled = canUndo } },
218+
.{ .command = .{ .id = "fizzy.redo", .title = .{ .dynamic = redoTitle }, .sf_symbol = "arrow.uturn.forward", .enabled = canRedo } },
198219
// Transform / Grid Layout are pixel-art concepts, not fizzy's own — pixi parents its own
199220
// section here rather than fizzy knowing about them.
200221
.{ .plugin_section = "fizzy.menu.edit" },

‎tests/integration.zig‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7533,6 +7533,32 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes
75337533
try std.testing.expectEqualStrings(before, (try read(host, notes_args)).text);
75347534
}
75357535

7536+
// The person's Cmd-Z taking a step someone else made: the menu names it, and the pane says
7537+
// so with a Redo that puts it back.
7538+
{
7539+
const front = editor.activeDoc() orelse return error.TestUnexpectedResult;
7540+
const before = (try read(host, "")).text;
7541+
const front_write = try std.fmt.allocPrint(gpa, ".{{ .path = \"{s}\", .text = \"// by Claude\\n\" }}", .{main_zig});
7542+
defer gpa.free(front_write);
7543+
const outer = host.act(.{ .kind = .agent, .plugin = "test", .session = 1, .label = "Claude" });
7544+
try std.testing.expect(host.callCommand("text.write", front_write, host.arena()) == .ok);
7545+
host.endAct(outer);
7546+
const taken = try editor.stepToTake(front, .undo, host.arena());
7547+
try std.testing.expectEqualStrings("Claude", fizzy.Editor.authorName(taken.by));
7548+
7549+
try editor.undo();
7550+
try std.testing.expectEqualStrings(before, (try read(host, "")).text);
7551+
const ds = editor.doc_surfaces.get(front.id) orelse return error.TestUnexpectedResult;
7552+
const said = ds.feed.items[ds.feed.len - 1];
7553+
try std.testing.expect(std.mem.startsWith(u8, said.title[0..said.len], "Undid Claude's "));
7554+
const redo = said.action orelse return error.TestUnexpectedResult;
7555+
try std.testing.expectEqual(taken.step.id, redo.tag);
7556+
editor.redoFromFeed(front, redo.tag);
7557+
try std.testing.expectEqualStrings("// by Claude\n", (try read(host, "")).text);
7558+
try editor.undo();
7559+
try std.testing.expectEqualStrings(before, (try read(host, "")).text);
7560+
}
7561+
75367562
// Save and Close, the unsaved-changes dialog's: the same write Save makes, then the tab
75377563
// closes once it lands.
75387564
try std.testing.expect(doc.owner.isDirty(doc));

0 commit comments

Comments
 (0)