46745bad
feat: every change to the wall writes the layout through one save path
a73x 2026-09-03 05:20
Commit message
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) { |