ddf6ae1d
feat: a trimmed seed stops the run saving, and a poll's vanish writes the file
a73x 2026-09-03 05:20
Commit message
src/tui/wall_host.zig
| Old | New | ||
|---|---|---|---|
| @@ -395,6 +395,12 @@ pub fn applyHostList(w: Wall, hi: usize) void { | |||
| 395 | wv.vanishTile(w.liveTiles(), w.livePresent(), w.shared, v, null); | 395 | wv.vanishTile(w.liveTiles(), w.livePresent(), w.shared, v, null); |
| 396 | changed = true; | 396 | changed = true; |
| 397 | } | 397 | } |
| 398 | // Once for the whole loop, through the one save path: a pane whose | ||
| 399 | // session the daemon no longer lists is off this wall, and a file | ||
| 400 | // that still named it would seat it again — as a `gone` pane the | ||
| 401 | // user has to dismiss by hand — on the next start. This runs on | ||
| 402 | // the keyboard thread (`applyReadyLists`), like every other save. | ||
| 403 | if (vanish.len > 0) wall_layout.persist(w); | ||
| 398 | // A name the list carries that no pane spells ends here, unsaid: the | 404 | // A name the list carries that no pane spells ends here, unsaid: the |
| 399 | // user's layout says which sessions this wall shows, and a session | 405 | // user's layout says which sessions this wall shows, and a session |
| 400 | // somebody else started on the same daemon is not this wall's to add | 406 | // somebody else started on the same daemon is not this wall's to add |
src/tui/wall_layout.zig
| Old | New | ||
|---|---|---|---|
| @@ -264,8 +264,15 @@ pub const SeedPlan = struct { | |||
| 264 | /// Leaves the file named that this wall does not seat: the session | 264 | /// Leaves the file named that this wall does not seat: the session |
| 265 | /// this `mux` runs inside, and whatever the terminal was too small to | 265 | /// this `mux` runs inside, and whatever the terminal was too small to |
| 266 | /// cut. Every OTHER disagreement with the file is a refusal, so this | 266 | /// cut. Every OTHER disagreement with the file is a refusal, so this |
| 267 | /// counts only what the wall chose to leave out. | 267 | /// counts only what the wall chose to leave out. Nonzero is what stops |
| 268 | /// `run` writing the file back — see `Shared.layout_path`. | ||
| 268 | dropped: usize, | 269 | dropped: usize, |
| 270 | /// How many of `dropped` are the session this `mux` runs inside; the | ||
| 271 | /// rest are panes the terminal could not cut. The two are counted | ||
| 272 | /// apart because they are different sentences to say: a wall that | ||
| 273 | /// blamed the terminal's size for a shell's own stripe would send the | ||
| 274 | /// user resizing a window that was never the problem. | ||
| 275 | dropped_self: usize, | ||
| 269 | 276 | ||
| 270 | pub fn deinit(self: *SeedPlan, alloc: std.mem.Allocator) void { | 277 | pub fn deinit(self: *SeedPlan, alloc: std.mem.Allocator) void { |
| 271 | for (self.panes) |mp| { | 278 | for (self.panes) |mp| { |
| @@ -363,6 +370,7 @@ pub fn seedLayout( | |||
| 363 | if (seedAttempt(alloc, shared, bytes, entry_at, keeps.items[0..n], base)) |plan| { | 370 | if (seedAttempt(alloc, shared, bytes, entry_at, keeps.items[0..n], base)) |plan| { |
| 364 | var out = plan; | 371 | var out = plan; |
| 365 | out.dropped = dropped + (keeps.items.len - n); | 372 | out.dropped = dropped + (keeps.items.len - n); |
| 373 | out.dropped_self = dropped; | ||
| 366 | return .{ .plan = out }; | 374 | return .{ .plan = out }; |
| 367 | } | 375 | } |
| 368 | if (n == 0) break; | 376 | if (n == 0) break; |
| @@ -411,6 +419,7 @@ fn seedAttempt( | |||
| 411 | }, | 419 | }, |
| 412 | .focus = focus, | 420 | .focus = focus, |
| 413 | .dropped = 0, | 421 | .dropped = 0, |
| 422 | .dropped_self = 0, | ||
| 414 | }; | 423 | }; |
| 415 | @memset(plan.panes, null); | 424 | @memset(plan.panes, null); |
| 416 | // Duped: a wall tile owns its two copies, and the birth that reuses a | 425 | // Duped: a wall tile owns its two copies, and the birth that reuses a |
src/tui/wall_test_host.zig
| Old | New | ||
|---|---|---|---|
| @@ -1088,3 +1088,49 @@ test "planHostDiff: a session the daemon has and the wall does not is nobody's b | |||
| 1088 | try std.testing.expectEqual(@as(usize, 0), gones.len); | 1088 | try std.testing.expectEqual(@as(usize, 0), gones.len); |
| 1089 | try std.testing.expectEqual(@as(usize, 3), wv.presentCount(&present)); | 1089 | try std.testing.expectEqual(@as(usize, 3), wv.presentCount(&present)); |
| 1090 | } | 1090 | } |
| 1091 | |||
| 1092 | test "applyHostList: a pane the daemon stopped listing leaves the layout file too" { | ||
| 1093 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); | ||
| 1094 | defer arena.deinit(); | ||
| 1095 | const alloc = arena.allocator(); | ||
| 1096 | var shared: Shared = undefined; | ||
| 1097 | fixture.stoppedWall(alloc, &shared); | ||
| 1098 | var tmp = try TmpDir.make(); | ||
| 1099 | defer tmp.cleanup(); | ||
| 1100 | var path_buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 1101 | const path = try std.fmt.bufPrint(&path_buf, "{s}/layout", .{tmp.path()}); | ||
| 1102 | shared.layout_path = path; | ||
| 1103 | var tiles: [4]Tile = undefined; | ||
| 1104 | var present = [_]bool{false} ** 4; | ||
| 1105 | var live: usize = 0; | ||
| 1106 | // Two panes on two HOSTS: the survivor has to be a pane the vanishing | ||
| 1107 | // one's host does not own, or a save that wrote the wrong slice would | ||
| 1108 | // still look right. | ||
| 1109 | var table = [_]Host{ | ||
| 1110 | fixture.testHost(&shared, "--sock /tmp/box.sock", "/tmp/box.sock"), | ||
| 1111 | fixture.testHost(&shared, "--sock /tmp/vm.sock", "/tmp/vm.sock"), | ||
| 1112 | }; | ||
| 1113 | try seatBound(&tiles, &present, &live, &shared, &.{ | ||
| 1114 | .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "--sock /tmp/box.sock#a", .session = "a" }, | ||
| 1115 | .{ .target = .{ .sock = "/tmp/vm.sock" }, .label = "--sock /tmp/vm.sock#b", .session = "b" }, | ||
| 1116 | }, &.{ 0, 1 }); | ||
| 1117 | tiles[0].alive.store(true, .release); | ||
| 1118 | tiles[1].alive.store(true, .release); | ||
| 1119 | const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table); | ||
| 1120 | // The wall as seated, on disk: what the poll is about to change. | ||
| 1121 | wall_layout.persist(w); | ||
| 1122 | const seated = try std.fs.cwd().readFileAlloc(alloc, path, 4096); | ||
| 1123 | try std.testing.expect(std.mem.indexOf(u8, seated, "--sock /tmp/box.sock#a") != null); | ||
| 1124 | |||
| 1125 | // Twice: the first list that loses a session is a grace, the second | ||
| 1126 | // vanishes the pane. | ||
| 1127 | fixture.setList(&table[0], ""); | ||
| 1128 | wall_host.applyHostList(w, 0); | ||
| 1129 | wall_host.applyHostList(w, 0); | ||
| 1130 | |||
| 1131 | try std.testing.expect(!present[0]); | ||
| 1132 | const after = try std.fs.cwd().readFileAlloc(alloc, path, 4096); | ||
| 1133 | try std.testing.expect(std.mem.indexOf(u8, after, "--sock /tmp/box.sock#a") == null); | ||
| 1134 | // The other host's pane is still the wall's, and still in the file. | ||
| 1135 | try std.testing.expect(std.mem.indexOf(u8, after, "--sock /tmp/vm.sock#b") != null); | ||
| 1136 | } | ||
src/tui/wall_test_layout.zig
| Old | New | ||
|---|---|---|---|
| @@ -1212,3 +1212,36 @@ test "persist: a birth and a vanish each write the layout, and no layout_path wr | |||
| 1212 | const second = try std.fs.cwd().readFileAlloc(alloc, path, 4096); | 1212 | const second = try std.fs.cwd().readFileAlloc(alloc, path, 4096); |
| 1213 | try std.testing.expect(std.mem.indexOf(u8, second, "#0") == null); | 1213 | try std.testing.expect(std.mem.indexOf(u8, second, "#0") == null); |
| 1214 | } | 1214 | } |
| 1215 | |||
| 1216 | test "seed: a plan that leaves leaves behind counts them apart - the shell's own stripe and the panes that would not fit" { | ||
| 1217 | const alloc = std.testing.allocator; | ||
| 1218 | var shared: Shared = undefined; | ||
| 1219 | seedShared(alloc, &shared); | ||
| 1220 | defer shared.tree.deinit(); | ||
| 1221 | // A tty, as every restoring wall is, so a label row counts toward each | ||
| 1222 | // pane's floor: seven rows hold two panes of three rows and not three. | ||
| 1223 | shared.is_tty = true; | ||
| 1224 | shared.size = .{ .cols = 100, .rows = 7 }; | ||
| 1225 | var table = [_]Host{ | ||
| 1226 | fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"), | ||
| 1227 | fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"), | ||
| 1228 | }; | ||
| 1229 | // Four panes across TWO hosts, one of them the session this shell is | ||
| 1230 | // standing in: the two reasons a leaf goes unseated happen at once, and | ||
| 1231 | // a fixture with only one of them cannot see them counted apart. | ||
| 1232 | table[0].self_name = "s"; | ||
| 1233 | const file = "mux-layout 1\nstacked 0\n" ++ | ||
| 1234 | " leaf 1 --sock /tmp/h0.sock#s\n leaf 1 --sock /tmp/h0.sock#a\n" ++ | ||
| 1235 | " leaf 1 --sock /tmp/h1.sock#b\n leaf 1 --sock /tmp/h1.sock#c\n"; | ||
| 1236 | var res = wall_layout.seedLayout(alloc, &table, &shared, file, null); | ||
| 1237 | defer if (res == .plan) res.plan.deinit(alloc); | ||
| 1238 | |||
| 1239 | try std.testing.expect(res == .plan); | ||
| 1240 | try std.testing.expectEqual(@as(usize, 2), res.plan.panes.len); | ||
| 1241 | // What `run` reads: nonzero `dropped` is a wall this terminal cannot | ||
| 1242 | // show whole, so the run stops saving rather than write the file back | ||
| 1243 | // without the leaves it left out. `dropped_self` is the half that no | ||
| 1244 | // terminal size would have helped, and it gets its own sentence. | ||
| 1245 | try std.testing.expectEqual(@as(usize, 2), res.plan.dropped); | ||
| 1246 | try std.testing.expectEqual(@as(usize, 1), res.plan.dropped_self); | ||
| 1247 | } | ||
src/tui/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -1664,6 +1664,25 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 1664 | else | 1664 | else |
| 1665 | null; | 1665 | null; |
| 1666 | const seeded = seed_plan != null; | 1666 | const seeded = seed_plan != null; |
| 1667 | // A seed that could not seat everything the file named is not this | ||
| 1668 | // run's to write back. `persist` serializes the tree it HAS, so the | ||
| 1669 | // first save would drop the leaves this wall left out — the user's | ||
| 1670 | // other panes gone before they touched a key, and no undo. The whole | ||
| 1671 | // run stops saving instead, and says so once; the next start on a | ||
| 1672 | // terminal that fits them saves again. | ||
| 1673 | if (seed_plan) |pl| { | ||
| 1674 | if (pl.dropped > 0) { | ||
| 1675 | if (shared.layout_path) |p| alloc.free(p); | ||
| 1676 | shared.layout_path = null; | ||
| 1677 | var why: [96]u8 = undefined; | ||
| 1678 | const fit = pl.dropped - pl.dropped_self; | ||
| 1679 | setNotice(&shared, if (fit > 0) | ||
| 1680 | std.fmt.bufPrint(&why, "[layout not saved: terminal too small for {d} of its panes]", .{fit}) catch | ||
| 1681 | "[layout not saved: terminal too small for it]" | ||
| 1682 | else | ||
| 1683 | "[layout not saved: it names this shell's own session]"); | ||
| 1684 | } | ||
| 1685 | } | ||
| 1667 | if (seed_plan == null and has_entry) shared.tree.addFirst(0) catch return 2; | 1686 | if (seed_plan == null and has_entry) shared.tree.addFirst(0) catch return 2; |
| 1668 | const init_flat = shared.tree.flatten( | 1687 | const init_flat = shared.tree.flatten( |
| 1669 | alloc, | 1688 | alloc, |