a73x

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
feat: a trimmed seed stops the run saving, and a poll's vanish writes the file

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,