a73x

46745bad

feat: every change to the wall writes the layout through one save path

a73x   2026-09-03 05:20

Commit message
feat: every change to the wall writes the layout through one save path

src/tui/wall_layout.zig
Old New
@@ -1,7 +1,8 @@
1 //! The pane tree's operations and the layout sidecar. `relayout` is the 1 //! The pane tree's operations and the layout sidecar. `relayout` is the
2 //! single flatten point that turns the tree into tile rects and paints the 2 //! single flatten point that turns the tree into tile rects and paints the
3 //! rails; the sidecar saves that tree on the last detach and `seedLayout` 3 //! rails; `persist` writes that tree after every change to it and
4 //! seats it back verbatim, refusing a file it cannot seat whole. 4 //! `seedLayout` seats it back verbatim, refusing a file it cannot seat
5 //! whole.
5 const std = @import("std"); 6 const std = @import("std");
6 const proto = @import("term").protocol; 7 const proto = @import("term").protocol;
7 const hosts = @import("client").hosts; 8 const hosts = @import("client").hosts;
@@ -187,10 +188,11 @@ pub fn relayout(w: Wall, sel: usize) void {
187 188
188 /// The file half of the seed: null on a pipe, a missing sidecar, or bytes 189 /// The file half of the seed: null on a pipe, a missing sidecar, or bytes
189 /// `seedLayout` refuses — and the wall then boots on today's default cut. 190 /// `seedLayout` refuses — and the wall then boots on today's default cut.
191 /// `layout_path` is the gate, the same one `persist` reads: a wall that
192 /// writes no file must not seed from one either, or the next start would
193 /// restore a shape this run has already stopped recording.
190 pub fn seedSidecar(alloc: std.mem.Allocator, table: []const Host, shared: *Shared, entry_spelling: ?[]const u8) ?SeedPlan { 194 pub fn seedSidecar(alloc: std.mem.Allocator, table: []const Host, shared: *Shared, entry_spelling: ?[]const u8) ?SeedPlan {
191 if (!shared.is_tty) return null; 195 const path = shared.layout_path orelse return null;
192 const path = hosts.layoutPath(alloc) catch return null;
193 defer alloc.free(path);
194 const bytes = loadLayout(alloc, path) orelse return null; 196 const bytes = loadLayout(alloc, path) orelse return null;
195 defer alloc.free(bytes); 197 defer alloc.free(bytes);
196 return switch (seedLayout(alloc, table, shared, bytes, entry_spelling)) { 198 return switch (seedLayout(alloc, table, shared, bytes, entry_spelling)) {
@@ -241,17 +243,12 @@ pub fn saveLayoutTo(
241 }; 243 };
242 } 244 }
243 245
244 /// Resolves the sidecar path from env and delegates to `saveLayoutTo`. 246 /// The one save path. Every change to the pane set or the tree comes
245 pub fn saveSidecar(w: Wall) void { 247 /// through here — a birth, a removal, a split, a resize, a detach — so two
246 // A pipe has no stripes, so it has no layout worth remembering — and 248 /// terminals on one device see each other's adds on their next start, and
247 // a tree it saved would be a tree the next TERMINAL seeds over the 249 /// a wall that crashes loses nothing it committed.
248 // aspect rule. `seedSidecar` refuses a pipe for the same reason. 250 pub fn persist(w: Wall) void {
249 if (!w.shared.is_tty) return; 251 const path = w.shared.layout_path orelse return;
250 const path = hosts.layoutPath(w.alloc) catch |err| {
251 std.debug.print("mux: wall layout not saved: {s}\n", .{@errorName(err)});
252 return;
253 };
254 defer w.alloc.free(path);
255 saveLayoutTo(w.alloc, path, w.liveTiles(), w.livePresent(), w.shared); 252 saveLayoutTo(w.alloc, path, w.liveTiles(), w.livePresent(), w.shared);
256 } 253 }
257 254
src/tui/wall_picker.zig
Old New
@@ -372,6 +372,10 @@ pub fn pickBirth(w: Wall, sel: usize) ?usize {
372 return null; 372 return null;
373 }; 373 };
374 wv.spawnPump(&w.tiles[at]); 374 wv.spawnPump(&w.tiles[at]);
375 // The pane is the wall's the moment it is born, not once its dial
376 // answers: a birth onto a box that turns out to be dark is still a pane
377 // the user authored, and the next start owes them it.
378 wall_layout.persist(w);
375 // The new session must not wait out a poll to be confirmed by the list 379 // The new session must not wait out a poll to be confirmed by the list
376 // that will also stop the diff from vanishing it. 380 // that will also stop the diff from vanishing it.
377 h.poll.poke.store(true, .release); 381 h.poll.poke.store(true, .release);
@@ -402,6 +406,10 @@ pub fn pickForget(w: Wall, sel: usize, path: ?[]const u8) void {
402 if (w.present[i] and wall_host.ownedBy(&w.tiles[i], sel)) 406 if (w.present[i] and wall_host.ownedBy(&w.tiles[i], sel))
403 wv.vanishTile(w.liveTiles(), w.livePresent(), w.shared, i, null); 407 wv.vanishTile(w.liveTiles(), w.livePresent(), w.shared, i, null);
404 } 408 }
409 // The host left the hosts file above; its panes leave the layout file
410 // here, in one write for the whole loop. A forget the file did not
411 // record would put every one of them back on the next start.
412 wall_layout.persist(w);
405 var buf: [96]u8 = undefined; 413 var buf: [96]u8 = undefined;
406 const said = if (why) |e| 414 const said = if (why) |e|
407 std.fmt.bufPrint(&buf, "[hosts file not updated: {s}]", .{hosts.reason(e)}) catch "[hosts file not updated]" 415 std.fmt.bufPrint(&buf, "[hosts file not updated: {s}]", .{hosts.reason(e)}) catch "[hosts file not updated]"
src/tui/wall_test_layout.zig
Old New
@@ -1164,3 +1164,51 @@ test "seedLayout: a terminal that holds one pane keeps the entry tile when the f
1164 try std.testing.expectEqual(@as(usize, 1), res.plan.dropped); 1164 try std.testing.expectEqual(@as(usize, 1), res.plan.dropped);
1165 try std.testing.expectEqual(@as(usize, 1), shared.tree.count()); 1165 try std.testing.expectEqual(@as(usize, 1), shared.tree.count());
1166 } 1166 }
1167
1168 test "persist: a birth and a vanish each write the layout, and no layout_path writes nothing" {
1169 // An arena: a birth dupes the tile's own session and label, and the wall
1170 // frees those only when a later birth takes the digit back.
1171 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
1172 defer arena.deinit();
1173 const alloc = arena.allocator();
1174 var tmp = try TmpDir.make();
1175 defer tmp.cleanup();
1176 var path_buf: [std.fs.max_path_bytes]u8 = undefined;
1177 const path = try std.fmt.bufPrint(&path_buf, "{s}/layout", .{tmp.path()});
1178
1179 var shared: Shared = undefined;
1180 fixture.stoppedWall(alloc, &shared);
1181 shared.size = .{ .cols = 120, .rows = 40 };
1182 var tiles: [wv.max_tiles]Tile = undefined;
1183 var present = [_]bool{false} ** wv.max_tiles;
1184 var live: usize = 0;
1185 defer fixture.endPumps(tiles[0..live]);
1186 var hosts_table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
1187 const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &hosts_table);
1188
1189 // Not yet a wall that persists: nothing is written.
1190 wall_layout.persist(w);
1191 try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(path, .{}));
1192
1193 shared.layout_path = path;
1194 const at = wv.birthTile(w, .{
1195 .r = .{ .target = hosts_table[0].spec.target, .label = "", .session = "0" },
1196 .from = 0,
1197 .place = .beside_focus,
1198 .creates = false,
1199 .born_from = null,
1200 .host = 0,
1201 .borrowed = true,
1202 }).?;
1203 // No pump was spawned, so nothing will ever clear the liveness a born
1204 // tile carries and `endPumps` would wait on it forever.
1205 tiles[at].alive.store(false, .release);
1206 wall_layout.persist(w);
1207 const first = try std.fs.cwd().readFileAlloc(alloc, path, 4096);
1208 try std.testing.expect(std.mem.indexOf(u8, first, "leaf 0 --sock /a#0") != null);
1209
1210 wv.vanishTile(w.liveTiles(), w.livePresent(), &shared, at, null);
1211 wall_layout.persist(w);
1212 const second = try std.fs.cwd().readFileAlloc(alloc, path, 4096);
1213 try std.testing.expect(std.mem.indexOf(u8, second, "#0") == null);
1214 }
src/tui/wallview.zig
Old New
@@ -105,6 +105,11 @@ pub const Shared = struct {
105 /// Whether there is a terminal here at all. Every WALL-level write is 105 /// Whether there is a terminal here at all. Every WALL-level write is
106 /// gated on it: on a pipe mux writes the grid and no terminal state. 106 /// gated on it: on a pipe mux writes the grid and no terminal state.
107 is_tty: bool, 107 is_tty: bool,
108 /// Where this wall is written, or null for a wall that persists nothing
109 /// (a piped `mux`, a test). Set once by `run` on a terminal. The ONE
110 /// gate every save reads, so a test points it at a file and proves what
111 /// an operation wrote rather than that a save was called.
112 layout_path: ?[]const u8 = null,
108 /// This client's own version, what a host's stated version is judged 113 /// This client's own version, what a host's stated version is judged
109 /// against for the bar's drift word. Empty means unknown, which judges 114 /// against for the bar's drift word. Empty means unknown, which judges
110 /// nobody — see `driftWord`. Set once before any pump exists, read-only 115 /// nobody — see `driftWord`. Set once before any pump exists, read-only
@@ -1620,6 +1625,13 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1620 }; 1625 };
1621 shared.tree = layout.Tree.init(alloc); 1626 shared.tree = layout.Tree.init(alloc);
1622 shared.flat_alloc = alloc; 1627 shared.flat_alloc = alloc;
1628 // Resolved before the seed reads it and before any tile is seated: a
1629 // pipe has no stripes, so it has no layout worth remembering — and a
1630 // tree it saved would be a tree the next TERMINAL seeds over the
1631 // aspect rule. A state dir that cannot be named leaves this null, and
1632 // the wall then runs exactly as a piped one does.
1633 if (is_tty) shared.layout_path = hosts.layoutPath(alloc) catch null;
1634 defer if (shared.layout_path) |p| alloc.free(p);
1623 const env_sock = std.posix.getenv(proto.sock_env); 1635 const env_sock = std.posix.getenv(proto.sock_env);
1624 const env_session = std.posix.getenv(proto.session_env); 1636 const env_session = std.posix.getenv(proto.session_env);
1625 // The host table before the first flatten, because the seed matches 1637 // The host table before the first flatten, because the seed matches
@@ -1803,6 +1815,13 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1803 // blank terminal with no cursor reads as hung), and a seeded wall 1815 // blank terminal with no cursor reads as hung), and a seeded wall
1804 // paints its remembered cut: rails and waiting bars have no pump. 1816 // paints its remembered cut: rails and waiting bars have no pump.
1805 if (w.live.* == 0 or seeded) wall_layout.relayout(w, shared.sel); 1817 if (w.live.* == 0 or seeded) wall_layout.relayout(w, shared.sel);
1818 // The entry tile is a pane the user just added to the wall, whichever
1819 // road seated it — `addFirst(0)` on a wall with no saved tree, or the
1820 // seed's own insertion beside the leaves the file named. Written here
1821 // and not at the exit, so `mux HOST` in one terminal is on the wall the
1822 // next terminal starts. A wall with no entry tile added nothing, and a
1823 // save there would write over a file this run may have refused to read.
1824 if (has_entry) wall_layout.persist(w);
1806 1825
1807 // One poller per host, all of them at once and none of them on this 1826 // One poller per host, all of them at once and none of them on this
1808 // thread: the user asked for one session and must not be held on 1827 // thread: the user asked for one session and must not be held on
@@ -1921,7 +1940,15 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1921 const before = presentCount(present[0..live]); 1940 const before = presentCount(present[0..live]);
1922 switch (addSessionTile(w, z, name.slice(), tiles[z].pending_place)) { 1941 switch (addSessionTile(w, z, name.slice(), tiles[z].pending_place)) {
1923 .moved => |to| { 1942 .moved => |to| {
1924 focusAnswer(w, presentCount(present[0..live]) > before, to); 1943 const grew = presentCount(present[0..live]) > before;
1944 focusAnswer(w, grew, to);
1945 // The chord's birth (`.new_session`, `.split_right`,
1946 // `.split_below`) lands here, one round trip after
1947 // the key: the answer is what makes the tile, so this
1948 // is where the wall changed. An answer that only
1949 // moved the focus onto a tile the wall already had
1950 // changed nothing to write.
1951 if (grew) wall_layout.persist(w);
1925 }, 1952 },
1926 .full => setNotice(&shared, "[no room on the wall for another tile]"), 1953 .full => setNotice(&shared, "[no room on the wall for another tile]"),
1927 .stay => {}, 1954 .stay => {},
@@ -1957,9 +1984,13 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1957 } 1984 }
1958 vanishTile(tiles[0..live], present[0..live], &shared, ended, v.back); 1985 vanishTile(tiles[0..live], present[0..live], &shared, ended, v.back);
1959 wall_layout.relayout(w, shared.sel); 1986 wall_layout.relayout(w, shared.sel);
1987 // A pane whose session ended is a pane the wall no
1988 // longer has: the file says so now, not at the next
1989 // detach, so a crash cannot bring the dead one back.
1990 wall_layout.persist(w);
1960 }, 1991 },
1961 .finish => |how| { 1992 .finish => |how| {
1962 wall_layout.saveSidecar(w); 1993 wall_layout.persist(w);
1963 exit_code = how.code; 1994 exit_code = how.code;
1964 exit_msg = how.msg; 1995 exit_msg = how.msg;
1965 break :keys; 1996 break :keys;
@@ -2142,6 +2173,9 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2142 // a box the user opened themselves. 2173 // a box the user opened themselves.
2143 picker_auto.taken(); 2174 picker_auto.taken();
2144 closePicker(w, &input.prefix, &picker_shown, birth_at); 2175 closePicker(w, &input.prefix, &picker_shown, birth_at);
2176 // `pickBirth` wrote the new pane; the close is what moved
2177 // the focus onto it, and the focus is in the file too.
2178 if (birth_at != null) wall_layout.persist(w);
2145 // The focused pump may be holding a claim the popup refused; 2179 // The focused pump may be holding a claim the popup refused;
2146 // it re-arms and retries on its next pass, and this is what 2180 // it re-arms and retries on its next pass, and this is what
2147 // makes that pass happen now rather than within a poll. 2181 // makes that pass happen now rather than within a poll.
@@ -2228,7 +2262,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2228 tiles[z].detach_req.store(true, .release); 2262 tiles[z].detach_req.store(true, .release);
2229 ring(&tiles[z]); 2263 ring(&tiles[z]);
2230 awaitDetach(&tiles[z], &shared); 2264 awaitDetach(&tiles[z], &shared);
2231 wall_layout.saveSidecar(w); 2265 wall_layout.persist(w);
2232 exit_code = 0; 2266 exit_code = 0;
2233 exit_msg = "mux: detached (session still running; run mux to reattach)"; 2267 exit_msg = "mux: detached (session still running; run mux to reattach)";
2234 break :keys; 2268 break :keys;
@@ -2283,7 +2317,10 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2283 wall_layout.relayout(w, shared.sel); 2317 wall_layout.relayout(w, shared.sel);
2284 }, 2318 },
2285 .resize => |d| { 2319 .resize => |d| {
2286 _ = wall_layout.doResize(w, z, d); 2320 // Only a resize that MOVED a boundary: a key the tree
2321 // refused left the same tree behind, and rewriting it
2322 // would cost a file write per held-down arrow.
2323 if (wall_layout.doResize(w, z, d)) wall_layout.persist(w);
2287 }, 2324 },
2288 .focus => |idx| { 2325 .focus => |idx| {
2289 if (idx > 0 and idx <= w.live.* and present[idx - 1] and idx - 1 != z) { 2326 if (idx > 0 and idx <= w.live.* and present[idx - 1] and idx - 1 != z) {