From ec89f340f239b31d6c9ef95e7e76696f9aa2005a Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 12:26:33 -0300 Subject: Two opens writing one body are separate undo steps, however they interleave The join mark a write left was the pane's, so another open's write joined the first open's step. Writing opens of body, data and xdata now get an open record, the mark names the open that set it, and another open's write starts its own step (the pane's one history: each turn the opens take is a step). Co-Authored-By: Claude Opus 5.5 --- docs/fs.md | 4 +++- src/ninep/pane.zig | 35 +++++++++++++++++++++++++++++++++++ src/ninep/tree.zig | 18 ++++++++++++------ 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/docs/fs.md b/docs/fs.md index dc15ab91..03cdd1ee 100644 --- a/docs/fs.md +++ b/docs/fs.md @@ -738,7 +738,9 @@ The three flag files `dirty`, `mark` and `scroll` read `0` or `1` and take an undo point (writing `1` pushes one now), and whether a write scrolls the pane. The writes of one open of `data`, `xdata` or `body` are one undo step while `mark` is 1 (so `printf 'a\nb\n' > data` is one, though a -shell writes it a line at a time), and the next open starts another; to +shell writes it a line at a time), and the next open starts another. The +pane has one history, so two opens writing at once take turns in it: each +open's first write after the other's starts a step of its own; to make a loop's writes one step, write `1` (an undo point here), then `0`, the writes, then `1` again. diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 9cb1dba6..9e832df6 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -56,6 +56,10 @@ pub const State = struct { /// `printf 'x\ny\n' > data` is one step, as acme's replace is, though /// bash writes it a line at a time; any other edit between breaks it. joined: ?u32 = null, + /// The open whose writes `joined` joins (0: any, as after a truncation + /// or for an open with no record): an interleaved open's write starts + /// a step of its own. + joined_by: u32 = 0, noscroll: bool = false, tag_snap: std.ArrayList(u8) = .empty, /// An open reads pty/data, so output queues for it; a second is refused. @@ -446,6 +450,12 @@ pub fn write(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply }; } +/// A write joins the step being made only when it is the same open's (or +/// the step is anyone's, `joined_by` 0): another open's starts its own. +fn joinOnly(pane: *Pane, handle: u32) void { + if (pane.fs.joined_by != 0 and pane.fs.joined_by != handle) pane.fs.joined = null; +} + fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; if (pane.file == null) { @@ -456,9 +466,11 @@ fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { return .{ .tag = req.tag, .written = @intCast(take) }; } const at = bodyOf(pane).len; + joinOnly(pane, req.handle); const take = spliceBody(p, pane, at, at, req.data) orelse return Reply.fail(req.tag, E.NOMEM); pane.fs.joined = fileOf(pane).?.revision; + pane.fs.joined_by = req.handle; if (!pane.fs.noscroll) showOffset(pane, at + take); return .{ .tag = req.tag, .written = @intCast(take) }; } @@ -511,9 +523,11 @@ fn writeData(p: *Pardes, req: Req, pane: *Pane) Reply { const q0: usize = pf.addr.q0; const q1: usize = @max(q0, @as(usize, pf.addr.q1)); const before = dotOf(pane); + joinOnly(pane, req.handle); const take = spliceBody(p, pane, q0, q1, req.data) orelse return Reply.fail(req.tag, E.NOMEM); pf.joined = fileOf(pane).?.revision; + pf.joined_by = req.handle; setDot(pane, .{ .q0 = shiftOne(before.q0, clip(q0), clip(q1 - q0), clip(take)), .q1 = shiftOne(before.q1, clip(q0), clip(q1 - q0), clip(take)), @@ -734,6 +748,7 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status { pf.joined = null; _ = spliceBody(p, pane, 0, bodyOf(pane).len, "") orelse return .err; pf.joined = fileOf(pane).?.revision; + pf.joined_by = 0; pf.addr = .{}; setDot(pane, .{}); }, @@ -745,6 +760,7 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status { pf.joined = null; _ = spliceBody(p, pane, q0, q1, "") orelse return .err; pf.joined = fileOf(pane).?.revision; + pf.joined_by = 0; setDot(pane, .{ .q0 = shiftOne(before.q0, q0, q1 - q0, 0), .q1 = shiftOne(before.q1, q0, q1 - q0, 0) }); pf.addr = .{ .q0 = q0, .q1 = q0 }; }, @@ -1057,6 +1073,25 @@ test "the writes of one open are one undo step, as bash's line-buffered printf m } } +test "two opens writing one body are two undo steps, however their writes interleave" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + const serial = serialOf(p); + const body = Node.of(serial, .body); + const a = call(p, .{ .tag = 1, .op = .open, .node = body, .omode = 1 }).reply.handle; + const b = call(p, .{ .tag = 1, .op = .open, .node = body, .omode = 1 }).reply.handle; + try testing.expect(a != 0 and b != 0 and a != b); + _ = call(p, .{ .tag = 2, .op = .write, .node = body, .handle = a, .data = "a\n" }); + _ = call(p, .{ .tag = 2, .op = .write, .node = body, .handle = a, .data = "b\n" }); + _ = call(p, .{ .tag = 2, .op = .write, .node = body, .handle = b, .data = "c\n" }); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status); + try testing.expectEqualStrings("one\na\nb\n", rd(p, body, 0, 64).bytes); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status); + try testing.expectEqualStrings("one\n", rd(p, body, 0, 64).bytes); + _ = call(p, .{ .tag = 3, .op = .release, .node = body, .handle = a, .opened = true }); + _ = call(p, .{ .tag = 3, .op = .release, .node = body, .handle = b, .opened = true }); +} + test "a name cut across writes is one name, applied once at its newline or its close" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index 4b52be51..66f73e9c 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -131,6 +131,9 @@ pub const Open = struct { /// An open of a file that takes lines of commands (look, exec, a /// ctl, tagexec): its `pending` and `results`. lines, + /// An open writing a file pane's body, data or xdata: its writes are + /// one undo step (pane.zig `joined_by`), another open's its own. + writes, } = .{ .snapshot = null }, /// What a write to a file of command lines left unfinished: a last line /// with no newline yet, or an Edit block still open. A write through a @@ -180,7 +183,7 @@ pub const Open = struct { gpa.free(run.output); gpa.free(run.line); }, - .event, .pty_data, .ctl, .lines => {}, + .event, .pty_data, .ctl, .lines, .writes => {}, } o.pending.deinit(gpa); if (o.replay) |r| gpa.free(r.bytes); @@ -752,7 +755,8 @@ fn open(p: *Pardes, req: Req, target: Target) Reply { pane_of = pn; const reader = req.omode & 3 != 1; // OWRITE is the one mode that never reads break :what switch (t.file) { - .body => if (pn.isTerminal()) .{ .snapshot = null } else return .{ .tag = req.tag }, + .body => if (pn.isTerminal()) .{ .snapshot = null } else if (req.omode & 3 == 0) return .{ .tag = req.tag } else .writes, + .data, .xdata => if (req.omode & 3 != 0) .writes else return .{ .tag = req.tag }, // Reads of these consume, so two readers would each see half // the stream; the second is refused rather than robbed. .event => if (reader and pn.fs.event_reader) return failText(req.tag, E.BUSY, e_in_use) else .{ .event = reader }, @@ -794,7 +798,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply { pn.fs.event_reader = pn.fs.event_reader or reader; }, .pty_data => pane_of.?.fs.pty_reader = true, - .run, .ctl, .lines => {}, + .run, .ctl, .lines, .writes => {}, } return .{ .tag = req.tag, .handle = @intCast(i + 1) }; } @@ -831,9 +835,11 @@ fn releaseHandle(p: *Pardes, req: Req) void { p.panes[id].?.fs.tag_rewrite = false; p.panes[id].?.fs.tag_held_newline = false; }; - // The writes of one open are one undo step (pane.zig `joined`). + // The writes of one open are one undo step (pane.zig `joined`): its + // close ends that step. if (t == .pane and (t.pane.file == .body or t.pane.file == .data or t.pane.file == .xdata)) if (p.paneBySerial(t.pane.serial)) |id| { - p.panes[id].?.fs.joined = null; + const pf = &p.panes[id].?.fs; + if (pf.joined_by == 0 or pf.joined_by == req.handle) pf.joined = null; }; // `: > limit` clears it for good once its open goes. if (t == .pane and t.pane.file == .limit) if (p.paneBySerial(t.pane.serial)) |id| { @@ -871,7 +877,7 @@ fn releaseHandle(p: *Pardes, req: Req) void { .ctl => if (pn.fs.lock == req.handle) { pn.fs.lock = null; }, - .snapshot, .log, .lines => {}, + .snapshot, .log, .lines, .writes => {}, }; o.deinit(p.gpa); } -- cgit v1.3