summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-10-01 19:50:32 -0300
committerGabriel Schneider <[email protected]>2026-10-01 19:57:27 -0300
commitdce94f4453735a6f71385cc020ee79c7604609e7 (patch)
tree266e031b92a282187467f5e35c1b0824b4b7a5f2 /src
parent2868516c215a96d9e1e933ded7ac5744ec16c368 (diff)
downloadpardes-dce94f4453735a6f71385cc020ee79c7604609e7.tar.gz
pardes-dce94f4453735a6f71385cc020ee79c7604609e7.zip
Zerox opens a second pane on a file pane's buffer: one text, undo and unsaved state, a scroll and a cursor each; an edit in either is spliced into both at File.setContentSpan, O(the edit) more, and what a Save, a get, the disk watch or a rename changes on one is copied to the rest after each step
Twins share the content buffer, the History and, while it is one, the line index; the other twin's cursor and scroll move past the splice as acme moves a clone's q0. Del of one twin asks nothing while another holds the text, Exit lists the text once, and a closed twin keeps a copy of its own until it is torn down. Both are in the index, each its serial, one name. An event reader on one hears no edit made through another (first cut). A dump keeps the twins as two panes on the file, not linked again. perf: edit-twin, typing beside a twin of the 50k-line file, gated within 3x of edit-char in the same run; the three baselines re-recorded for it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/File.zig239
-rw-r--r--src/builtins.zig28
-rw-r--r--src/config.zig1
-rw-r--r--src/exec.zig1
-rw-r--r--src/ninep/ctl.zig56
-rw-r--r--src/pardes.zig11
6 files changed, 331 insertions, 5 deletions
diff --git a/src/File.zig b/src/File.zig
index 3ef1593d..522d4bd6 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -3,6 +3,7 @@
const panes = @import("panes.zig");
const std = @import("std");
const vaxis = @import("vaxis");
+const builtin = @import("builtin");
const pardes = @import("pardes.zig");
const config = @import("config.zig");
const Pardes = pardes.Pardes;
@@ -107,6 +108,10 @@ pub const State = struct {
/// to; gpa-owned ranges and the primary's index.
node_history: std.ArrayList(NodeStep) = .empty,
history: *History,
+ /// Zerox's group: the panes on one buffer share `content`, `history`
+ /// and (while it is the one index) `line_starts`, each with its own
+ /// scroll, cursor and syntax; 0, none. See `zerox`.
+ twin: u32 = 0,
};
pub const NodeStep = struct { ranges: []modal.Selection, primary: usize };
@@ -992,11 +997,17 @@ pub fn deinit(p: *Pardes, pane: *Pane, file: *State) void {
if (file.output == null) for (p.panes, 0..) |slot, id| {
if (slot == pane) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
};
+ // A twin's text, undo and shared index stay with the twins left.
+ var twins: [pardes.MAX_PANES]usize = undefined;
+ const ntwins = twinsOf(p, file, &twins);
+ const index_shared = for (twins[0..ntwins]) |id| {
+ if (p.panes[id].?.file.?.line_starts.ptr == file.line_starts.ptr) break true;
+ } else false;
p.gpa.free(file.path);
- p.gpa.free(file.content);
+ if (ntwins == 0) p.gpa.free(file.content);
if (file.listing) |listing| p.gpa.free(listing.names);
if (file.mini) |*mini| mini.deinit(p.gpa);
- if (file.line_starts.len > 0) p.gpa.free(file.line_starts);
+ if (file.line_starts.len > 0 and !index_shared) p.gpa.free(file.line_starts);
if (file.highlights.len > 0) p.tree_sitter_gpa.free(file.highlights);
p.gpa.free(file.pager_spans);
locations.freeRows(p.gpa, file.location_rows);
@@ -1004,6 +1015,7 @@ pub fn deinit(p: *Pardes, pane: *Pane, file: *State) void {
if (file.node_tree) |tree| syntax.freeTree(tree);
for (file.node_history.items) |step| p.gpa.free(step.ranges);
file.node_history.deinit(p.gpa);
+ if (ntwins > 0) return;
for (file.history.undo[0..file.history.undo_len]) |snap| p.gpa.free(snap.content);
for (file.history.redo[0..file.history.redo_len]) |snap| p.gpa.free(snap.content);
p.gpa.destroy(file.history);
@@ -1024,7 +1036,20 @@ pub fn setContent(p: *Pardes, f: *State, new: []u8) void {
/// `setContent` for a splice that knows what it left alone at either end,
/// so the line index is not found again by comparing the whole text.
-pub fn setContentSpan(p: *Pardes, f: *State, new: []u8, span: ?Span) void {
+pub fn setContentSpan(p: *Pardes, f: *State, new: []u8, span_in: ?Span) void {
+ // Twins (Zerox) take the same splice: where it is, found once here when
+ // the caller did not say, is what the line index wants anyway.
+ var twins: [pardes.MAX_PANES]usize = undefined;
+ const ntwins = twinsOf(p, f, &twins);
+ const span: ?Span = span_in orelse if (ntwins > 0) spanOf(f.content, new) else null;
+ var moved: [pardes.MAX_PANES]struct { cursor: usize, scroll: usize } = undefined;
+ for (twins[0..ntwins], 0..) |id, i| {
+ const t = p.panes[id].?;
+ const tf = &t.file.?;
+ const at = lineStart(p.gpa, tf, @intCast(@max(0, t.body.cur_row))) + @as(usize, @intCast(@max(0, t.body.cur_col)));
+ moved[i] = .{ .cursor = spliced(@min(at, f.content.len), f.content.len, new.len, span.?), .scroll = spliced(lineStart(p.gpa, tf, tf.scroll), f.content.len, new.len, span.?) };
+ }
+ const old_starts = f.line_starts.ptr;
locations.freeRows(p.gpa, f.location_rows);
f.location_rows = &.{};
p.gpa.free(f.pager_spans);
@@ -1061,6 +1086,214 @@ pub fn setContentSpan(p: *Pardes, f: *State, new: []u8, span: ?Span) void {
f.highlight_start = 0;
f.syntax_dirty = true;
if (std.mem.endsWith(u8, f.path, "/+Pager")) f.pager_diff = looksLikeDiff(new[0..@min(new.len, 64 * 1024)]);
+ for (twins[0..ntwins], 0..) |id, i| {
+ const t = p.panes[id].?;
+ const tf = &t.file.?;
+ // Its own index, built while the shared one was empty, goes; the
+ // shared one, freed above, is replaced.
+ if (tf.line_starts.len > 0 and tf.line_starts.ptr != old_starts) p.gpa.free(tf.line_starts);
+ tf.content = f.content;
+ tf.line_starts = f.line_starts;
+ tf.revision = f.revision;
+ tf.saved_revision = f.saved_revision;
+ tf.saved_hash = f.saved_hash;
+ tf.saved_len = f.saved_len;
+ tf.mtime = f.mtime;
+ if (tf.highlights.len > 0) p.tree_sitter_gpa.free(tf.highlights);
+ tf.highlights = &.{};
+ tf.highlight_start = 0;
+ tf.syntax_dirty = true;
+ locations.freeRows(p.gpa, tf.location_rows);
+ tf.location_rows = &.{};
+ if (tf.mini) |*mini| mini.deinit(p.gpa);
+ tf.mini = null;
+ for (0..t.sel.len) |button| {
+ t.clearPointerSelection(button);
+ t.sel[button].state = .none;
+ }
+ t.body.vsel.active = false;
+ t.body.msel.active = false;
+ t.body.nsel = 0;
+ const cursor = positionIn(p.gpa, tf, moved[i].cursor);
+ t.body.cur_row = @intCast(cursor.row);
+ t.body.cur_col = @intCast(cursor.col);
+ tf.scroll = positionIn(p.gpa, tf, moved[i].scroll).row;
+ }
+}
+
+/// Where an offset into the old text is in the new, after the splice that
+/// kept `span` at either end: past it, moved by what it added; inside it,
+/// at its start, as acme moves a clone's q0 (textinsert, textdelete).
+fn spliced(at: usize, old_len: usize, new_len: usize, span: Span) usize {
+ if (at <= span.head) return at;
+ if (at >= old_len - span.tail) return at - (old_len - span.tail) + (new_len - span.tail);
+ return span.head;
+}
+
+/// What `old` and `new` agree on at either end.
+fn spanOf(old: []const u8, new: []const u8) Span {
+ const n = @min(old.len, new.len);
+ const head = std.mem.indexOfDiff(u8, old[0..n], new[0..n]) orelse n;
+ var tail: usize = 0;
+ while (tail < n - head and old[old.len - 1 - tail] == new[new.len - 1 - tail]) tail += 1;
+ return .{ .head = head, .tail = tail };
+}
+
+fn positionIn(gpa: std.mem.Allocator, f: *State, at: usize) struct { row: usize, col: usize } {
+ const index = lineIndex(gpa, f) catch {
+ const c = modal.positionAt(f.content, at);
+ return .{ .row = c.row, .col = c.col };
+ };
+ const row = std.sort.upperBound(usize, index, at, struct {
+ fn order(key: usize, item: usize) std.math.Order {
+ return std.math.order(key, item);
+ }
+ }.order) -| 1;
+ return .{ .row = row, .col = at - index[row] };
+}
+
+/// The other panes on `f`'s buffer (Zerox), by id.
+pub fn twinsOf(p: *Pardes, f: *const State, out: *[pardes.MAX_PANES]usize) usize {
+ if (f.twin == 0) return 0;
+ var n: usize = 0;
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ const other = if (pane.file) |*file| file else continue;
+ if (other == f or other.twin != f.twin) continue;
+ out[n] = id;
+ n += 1;
+ }
+ return n;
+}
+
+/// acme's Zerox: pane `to` made on `from`'s buffer, its text, undo and
+/// dirty state shared, its scroll and cursor its own. An edit in either is
+/// spliced into both (setContentSpan, O(the edit) more); what Save and the
+/// disk watch change on one is copied to the others after each step
+/// (syncTwins). An event reader on one hears no edit made in another.
+pub fn zerox(p: *Pardes, from: usize, to: usize) !*Pane {
+ const src = p.panes[from].?;
+ const f = &src.file.?;
+ const path = try p.gpa.dupe(u8, f.path);
+ errdefer p.gpa.free(path);
+ if (f.twin == 0) {
+ const group = try p.gpa.dupe(u8, f.path);
+ errdefer p.gpa.free(group);
+ try p.twin_groups.append(p.gpa, .{ .id = p.next_twin, .saved_revision = f.saved_revision, .saved_hash = f.saved_hash, .saved_len = f.saved_len, .disk_newer = f.disk_newer, .disk_gone = f.disk_gone, .path = group });
+ f.twin = p.next_twin;
+ p.next_twin +%= 1;
+ }
+ const pane = try p.newDocPane(to);
+ pane.file = .{
+ .path = path,
+ .content = f.content,
+ .line_starts = f.line_starts,
+ .history = f.history,
+ .revision = f.revision,
+ .saved_revision = f.saved_revision,
+ .saved_hash = f.saved_hash,
+ .saved_len = f.saved_len,
+ .disk_newer = f.disk_newer,
+ .disk_gone = f.disk_gone,
+ .mtime = f.mtime,
+ .scroll = f.scroll,
+ .tree_context = f.tree_context,
+ .twin = f.twin,
+ };
+ pane.body.cur_row = src.body.cur_row;
+ pane.body.cur_col = src.body.cur_col;
+ pane.body.cur_pinned = true;
+ if (filesystem.localPath(path) != null) p.emit(.{ .watch = .{ .pane = @intCast(to), .on = true } });
+ return pane;
+}
+
+/// A twin closed: it keeps a text of its own until it is torn down (a
+/// retired pane is drawn going), and the twins keep the shared one, its
+/// undo and its index.
+pub fn leaveTwins(p: *Pardes, f: *State) void {
+ var twins: [pardes.MAX_PANES]usize = undefined;
+ const n = twinsOf(p, f, &twins);
+ f.twin = 0;
+ if (n == 0) return;
+ const shared_index = for (twins[0..n]) |id| {
+ if (p.panes[id].?.file.?.line_starts.ptr == f.line_starts.ptr) break true;
+ } else false;
+ if (shared_index) f.line_starts = &.{};
+ f.content = p.gpa.dupe(u8, f.content) catch p.gpa.alloc(u8, 0) catch &.{};
+ f.history = History.create(p.gpa) catch f.history;
+ // The one left is no twin now (syncTwins drops the group).
+ if (n == 1) p.panes[twins[0]].?.file.?.twin = 0;
+}
+
+pub const TwinGroup = struct {
+ id: u32,
+ saved_revision: u32,
+ saved_hash: ?u64,
+ saved_len: ?usize,
+ disk_newer: ?u64,
+ disk_gone: bool,
+ path: []u8,
+};
+
+/// After each step: what a Save, a get, the disk watch or a rename changed
+/// on one twin is copied to the others; a group down to one pane is no
+/// group. O(panes) a step, and nothing at all with no twins.
+pub fn syncTwins(p: *Pardes) void {
+ var g: usize = 0;
+ while (g < p.twin_groups.items.len) {
+ const group = &p.twin_groups.items[g];
+ var members: [pardes.MAX_PANES]*State = undefined;
+ var n: usize = 0;
+ for (p.panes) |slot| {
+ const pane = slot orelse continue;
+ const f = if (pane.file) |*file| file else continue;
+ if (f.twin == group.id) {
+ members[n] = f;
+ n += 1;
+ }
+ }
+ if (n < 2) {
+ if (n == 1) members[0].twin = 0;
+ p.gpa.free(group.path);
+ _ = p.twin_groups.swapRemove(g);
+ continue;
+ }
+ for (members[0..n]) |f| {
+ if (f.saved_revision == group.saved_revision and f.saved_hash == group.saved_hash and f.saved_len == group.saved_len and
+ f.disk_newer == group.disk_newer and f.disk_gone == group.disk_gone and std.mem.eql(u8, f.path, group.path)) continue;
+ // This one changed: the group, and every other twin, take it.
+ if (!std.mem.eql(u8, f.path, group.path)) {
+ const path = p.gpa.dupe(u8, f.path) catch break;
+ p.gpa.free(group.path);
+ group.path = path;
+ }
+ group.saved_revision = f.saved_revision;
+ group.saved_hash = f.saved_hash;
+ group.saved_len = f.saved_len;
+ group.disk_newer = f.disk_newer;
+ group.disk_gone = f.disk_gone;
+ for (members[0..n]) |other| {
+ if (other == f) continue;
+ other.saved_revision = f.saved_revision;
+ other.saved_hash = f.saved_hash;
+ other.saved_len = f.saved_len;
+ other.disk_newer = f.disk_newer;
+ other.disk_gone = f.disk_gone;
+ if (!std.mem.eql(u8, other.path, f.path)) {
+ const path = p.gpa.dupe(u8, f.path) catch continue;
+ p.gpa.free(other.path);
+ other.path = path;
+ }
+ }
+ break;
+ }
+ // Tests hold the twins to one text, one revision, every step.
+ if (builtin.is_test) for (members[1..n]) |other| {
+ std.debug.assert(other.content.ptr == members[0].content.ptr and other.content.len == members[0].content.len);
+ std.debug.assert(other.revision == members[0].revision);
+ };
+ g += 1;
+ }
}
/// Whether `head`, a text's first bytes, holds a diff: a `diff` header, or
diff --git a/src/builtins.zig b/src/builtins.zig
index 5fdabd87..da87ba37 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -575,6 +575,22 @@ fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool {
.column => |col| if ((layout.findPane(c.p, id) orelse continue).col != col) continue,
}
const f = pane.file orelse continue;
+ // Twins (Zerox) hold one text: closing one while another stays
+ // loses nothing, and the text is asked about once.
+ if (f.twin != 0) {
+ const asked_elsewhere = for (c.p.panes, 0..) |other_slot, other_id| {
+ const other = other_slot orelse continue;
+ const of = other.file orelse continue;
+ if (other_id == id or of.twin != f.twin) continue;
+ const closing = switch (which) {
+ .all => true,
+ .pane => |only| other_id == only,
+ .column => |col| if (layout.findPane(c.p, other_id)) |at| at.col == col else false,
+ };
+ if (!closing or other_id < id) break true;
+ } else false;
+ if (asked_elsewhere) continue;
+ }
// A +New scratch saves nowhere, but what is typed in it is as lost;
// under 100 bytes it is not asked about, as acme's winclean does not
// whine about a small unnamed window, nor about a command's output.
@@ -1196,6 +1212,18 @@ pub const Incl = struct {
}
};
+/// acme's Zerox: a second pane on this pane's file, the same text, undo and
+/// unsaved state, with a scroll and a cursor of its own (File.zerox).
+pub const Zerox = struct {
+ pub fn run(c: Ctx) void {
+ const f = c.pane.file orelse return c.p.reportFailure(c.id, "Zerox: only a file pane takes it");
+ if (f.output != null or f.listing != null or f.mini != null) return c.p.reportFailure(c.id, "Zerox: only a file pane takes it");
+ const free = c.p.freeSlot() orelse return c.p.reportError(c.id, "Zerox", error.NoPaneSlots);
+ _ = panes.File.zerox(c.p, c.id, free) catch |err| return c.p.reportError(c.id, "Zerox", err);
+ exec.placeNew(c.p, c.id, c.id, free, .doc);
+ }
+};
+
/// Write the pane's text to its file, or to the file its argument names.
pub const Save = struct {
pub const takes_arg = true;
diff --git a/src/config.zig b/src/config.zig
index 89e5f813..26e1de2c 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -62,6 +62,7 @@ pub const leader_path = paths: {
.Putall = null,
.Incl = null,
.Tab = null,
+ .Zerox = null,
.ClipYank = "y",
.ClipYankMain = "Y",
.ClipPaste = "p",
diff --git a/src/exec.zig b/src/exec.zig
index 1ee57520..9aa8a8cb 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -948,7 +948,6 @@ pub const acme_words = [_]struct { acme: []const u8, pardes: ?[]const u8, hint:
.{ .acme = "Snarf", .pardes = null, .hint = ": y yanks the selection" },
.{ .acme = "Cut", .pardes = null, .hint = ": d deletes the selection into a register" },
.{ .acme = "Paste", .pardes = null, .hint = ": p puts a register" },
- .{ .acme = "Zerox", .pardes = null },
.{ .acme = "Sort", .pardes = null },
.{ .acme = "Load", .pardes = "Restore" },
.{ .acme = "ID", .pardes = null },
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 188795de..537ac4d0 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -1829,6 +1829,60 @@ test "Putall saves every pane with unsaved edits to a file, one each, and leaves
try testing.expectEqual(@as(usize, 1), saves);
}
+test "Zerox makes a second pane on one buffer: one text, undo and dirty state, its own cursor; Save cleans both, Del of one asks nothing" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ const p = try withFile(testing.allocator, "one\ntwo\nthree\n");
+ defer p.deinit();
+ const first = serialOf(p);
+ var name: [std.fs.max_path_bytes + 8]u8 = undefined;
+ _ = wr(p, Node.of(first, .name), try std.fmt.bufPrint(&name, "{s}/z.txt\n", .{dir}));
+ try testing.expect(p.executeBuiltinLine(0, "Zerox"));
+ const twin_id = for (p.panes, 0..) |slot, id| {
+ if (id != 0) if (slot) |pane| if (pane.file != null) break id;
+ } else return error.NoTwin;
+ const a = p.panes[0].?;
+ const b = p.panes[twin_id].?;
+ // Both in the index, each its serial, one name.
+ const index = rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes;
+ try testing.expectEqual(@as(usize, 2), std.mem.count(u8, index, "/z.txt"));
+ // b's cursor on `three`; a line written above it in a moves it down.
+ b.body.cur_row = 2;
+ b.body.cur_col = 1;
+ _ = wr(p, Node.of(first, .addr), "0");
+ try testing.expectEqual(Status.ok, wr(p, Node.of(first, .data), "zero\n").reply.status);
+ try testing.expectEqualStrings("zero\none\ntwo\nthree\n", b.file.?.content);
+ try testing.expectEqual(a.file.?.content.ptr, b.file.?.content.ptr);
+ try testing.expectEqual(@as(i32, 3), b.body.cur_row);
+ try testing.expectEqual(@as(i32, 1), b.body.cur_col);
+ try testing.expect(pane_files.dirtyOf(a) and pane_files.dirtyOf(b));
+ // A write through b is a's too; Undo in a takes it back from both.
+ try testing.expectEqual(Status.ok, wr(p, Node.of(b.serial, .body), "four\n").reply.status);
+ try testing.expectEqualStrings("zero\none\ntwo\nthree\nfour\n", a.file.?.content);
+ try testing.expect(p.executeBuiltinLine(0, "Undo"));
+ try testing.expectEqualStrings("zero\none\ntwo\nthree\n", b.file.?.content);
+ // Saved from b: both come clean.
+ try testing.expect(p.executeBuiltinLine(twin_id, "Save"));
+ while (p.nextEffect()) |e| p.perform(e);
+ try testing.expect(!pane_files.dirtyOf(a) and !pane_files.dirtyOf(b));
+ try testing.expectEqualStrings("zero\none\ntwo\nthree\n", p.fallback.get(b.file.?.path).?);
+ // Edited again, Del of a asks nothing: b holds the text.
+ try testing.expectEqual(Status.ok, wr(p, Node.of(b.serial, .body), "five\n").reply.status);
+ const failures = p.fs.failures;
+ try testing.expect(p.executeBuiltinLine(0, "Del"));
+ p.sync();
+ try testing.expectEqual(failures, p.fs.failures);
+ try testing.expect(p.paneBySerial(first) == null);
+ try testing.expect(pane_files.dirtyOf(b));
+ try testing.expectEqualStrings("zero\none\ntwo\nthree\nfive\n", b.file.?.content);
+ // Alone now, it is no twin; Del asks about it as about any pane.
+ try testing.expectEqual(@as(u32, 0), b.file.?.twin);
+ try testing.expect(p.executeBuiltinLine(p.paneBySerial(b.serial).?, "Del"));
+ try testing.expect(p.fs.failures != failures);
+}
+
test "a body write to a terminal is typed input, not a paste, even to a program that asked for bracketed paste" {
const p = try Pardes.init(testing.allocator, .{ .cols = 90, .rows = 30 });
defer p.deinit();
@@ -3211,7 +3265,7 @@ test "acme's words run as pardes's (Put is Save, Delete a Del that does not ask)
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
const serial = serialOf(p);
- for ([_][]const u8{ "Snarf\n", "Zerox\n", "Indent\n", "Local\n", "Abort\n" }) |line| {
+ for ([_][]const u8{ "Snarf\n", "Indent\n", "Local\n", "Abort\n" }) |line| {
for ([_]u64{ Node.of(serial, .ctl), Node.of(serial, .exec) }) |node| {
const r = wr(p, node, line);
try testing.expectEqual(E.INVAL, r.errno());
diff --git a/src/pardes.zig b/src/pardes.zig
index af2b80a2..7ae73636 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4369,6 +4369,10 @@ pub const Pardes = struct {
/// (`stdio.h`, `<foo.h>`) is tried in, in order. The session's, seeded
/// with C's (/usr/include, /usr/local/include), kept in a dump.
incl: std.ArrayList([]u8) = .empty,
+ /// Zerox's groups of panes on one buffer (File.syncTwins), and the id
+ /// the next takes.
+ twin_groups: std.ArrayList(panes.File.TwinGroup) = .empty,
+ next_twin: u32 = 1,
/// The kept list was read (or there was none): only then is it written,
/// so a session never writes over one it did not read.
recent_loaded: bool = false,
@@ -4872,6 +4876,8 @@ pub const Pardes = struct {
p.recent.deinit(p.recentGpa());
for (p.incl.items) |dir| p.gpa.free(dir);
p.incl.deinit(p.gpa);
+ for (p.twin_groups.items) |group| p.gpa.free(group.path);
+ p.twin_groups.deinit(p.gpa);
p.fs.batch.bytes.deinit(p.gpa);
if (p.stand_in) |pane| p.teardownPane(pane);
look.cancelLookHover(p);
@@ -5006,6 +5012,7 @@ pub const Pardes = struct {
// to it reopen it there.
p.noteClosed(pane);
panes.Output.dropUnsaved(p, pane);
+ if (pane.file) |*file| panes.File.leaveTwins(p, file);
const watched = (if (pane.file) |f| f.output == null else false) or pane.hasPdf();
for (p.panes, 0..) |slot, id| if (slot == pane) {
if (watched) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
@@ -5682,6 +5689,8 @@ pub const Pardes = struct {
}
pub fn perform(p: *Pardes, e: Effect) void {
+ // A Save performed on one twin is every twin's (File.syncTwins).
+ defer panes.File.syncTwins(p);
const v = p.host.vtable;
switch (e) {
// The host reads the pane (its command, its shell) as it forks,
@@ -5862,6 +5871,7 @@ pub const Pardes = struct {
/// that changes a pane costs a frame, which is why a round trip on a
/// local socket costs microseconds and not a vsync.
pub fn serveFs(p: *Pardes, req: ctlfs.Req) ctlfs.Reply {
+ defer panes.File.syncTwins(p);
if (!ctlfs.changesPane(p, req)) return ctlfs.handle(p, req);
// An address, a limit or the mark flag is nothing drawn: a script's
// addr write then its close costs no frame between them.
@@ -5892,6 +5902,7 @@ pub const Pardes = struct {
}
pub fn update(p: *Pardes, ev: Event) void {
+ defer panes.File.syncTwins(p);
defer p.reportFocus();
// A tick with nothing animating is the one event that cannot change
// the screen.