From 07a3f53993a27c886ccb3ed665acb9c8b34a2873 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:12:29 -0300 Subject: A tag's edited text stays alive until the next edit, so ms and md in a tag read it whole setEditText freed the text a tag edit was handed once it had copied the tag's own part out of it, but every caller reads that text back to place the cursor, and surround goes on to edit it again: ms and md in a tag read freed memory. The same held for a terminal without an edit buffer and a text with no pane. The text is now kept until the next such edit (edit.retireEditText), which covers every caller at once; the callers the audit found reading it back are surround's two, the paste and replace paths, the number adjust, and the insert-mode Enter. Co-Authored-By: Claude Opus 5.5 --- src/Terminal.zig | 2 +- src/edit.zig | 13 +++++++++++-- src/pardes.zig | 3 +++ test/panes.zig | 13 +++++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/Terminal.zig b/src/Terminal.zig index e3378007..4f94533d 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -1382,7 +1382,7 @@ pub fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { /// Consume a rewritten overlay, freeing the terminal edit text it replaces. pub fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void { - const overlay = if (pane.ovl) |*value| value else return p.gpa.free(new); + const overlay = if (pane.ovl) |*value| value else return pardes.edit.retireEditText(p, new); for (0..pane.sel.len) |button| { pane.clearPointerSelection(button); pane.sel[button].state = .none; diff --git a/src/edit.zig b/src/edit.zig index 8f6f09c6..3e12f169 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -463,7 +463,9 @@ pub fn setEditText(p: *Pardes, t: *Text, new: []u8) void { if (t.what == .tag) { // A tag is edited as it is shown, but only what follows its // prefix is its own: an edit that changed the prefix is refused. - defer p.gpa.free(new); + // The shown text stays alive until the next such edit, since every + // caller reads the new text back, as a body's is kept. + defer retireEditText(p, new); const pane = t.pane() orelse return; const prefix = tagline.pathPrefix(p.scratch.allocator(), pane) catch return; if (!std.mem.startsWith(u8, new, prefix)) { @@ -480,11 +482,18 @@ pub fn setEditText(p: *Pardes, t: *Text, new: []u8) void { t.own = new; return; } - const pane = t.pane() orelse return p.gpa.free(new); + const pane = t.pane() orelse return retireEditText(p, new); if (pane.file) |*f| return panes.File.setContent(p, f, new); panes.Terminal.setEditText(p, pane, new); } +/// Take a text an edit made that nothing keeps, holding it until the next +/// one: the edit's caller still reads it to place the cursor. +pub fn retireEditText(p: *Pardes, new: []u8) void { + if (p.retired_edit) |old| p.gpa.free(old); + p.retired_edit = new; +} + pub const InsertAt = enum { at, append, line_start, line_end, open_below, open_above }; /// Files insert only inside existing rows and at grapheme boundaries. diff --git a/src/pardes.zig b/src/pardes.zig index e4645d70..c9d93979 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4096,6 +4096,8 @@ pub const Pardes = struct { in_len: usize = 0, registers: Registers = .{}, + /// an edited text nothing kept (a tag's shown text): see edit.retireEditText + retired_edit: ?[]u8 = null, clip_pending: ?edit.ClipRequest = null, /// the last serialized dump (gpa-owned), read by the write_dump effect dump_out: ?[]u8 = null, @@ -4267,6 +4269,7 @@ pub const Pardes = struct { for (&p.col_tags) |*t| t.deinit(gpa); p.global_tag.deinit(gpa); p.registers.deinit(gpa); + if (p.retired_edit) |r| gpa.free(r); if (p.dump_out) |d| gpa.free(d); p.fallback.deinit(); if (p.last_dump) |d| gpa.free(d); diff --git a/test/panes.zig b/test/panes.zig index 4e98f449..0b9909ad 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -314,6 +314,19 @@ const TagNavigationTests = struct { try std.testing.expect(pane.prompt == .none and pane.focus == .body); } + test "ms and md edit a tag's own text, and the text the edit made is read back whole" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 20 }); + defer p.deinit(); + const pane = try p.setTestFile("body\n"); + p.update(.tick); + key(p, ':'); // the tag, on `Save` the first time + for ("miwms(") |cp| key(p, cp); + try std.testing.expect(std.mem.indexOf(u8, pardes.tagline.curTail(pane), "(Save)") != null); + for ("md(") |cp| key(p, cp); + try std.testing.expect(std.mem.indexOf(u8, pardes.tagline.curTail(pane), " Save ") != null); + try std.testing.expect(std.mem.indexOf(u8, pardes.tagline.curTail(pane), "(") == null); + } + test "the keyboard reaches a tag's path, which it can select and yank but not edit" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 20 }); defer p.deinit(); -- cgit v1.3