60e26d29
feat: the wall's first paint is the saved cut, and the settle owes one re-cut
a73x 2026-08-30 16:50
Commit message
docscheck.blocks
| Old | New | ||
|---|---|---|---|
| @@ -45,7 +45,7 @@ term.zig 0 | |||
| 45 | testtmp.zig 1 | 45 | testtmp.zig 1 |
| 46 | upgrade.zig 2 | 46 | upgrade.zig 2 |
| 47 | wall_host.zig 7 | 47 | wall_host.zig 7 |
| 48 | wall_layout.zig 4 | 48 | wall_layout.zig 2 |
| 49 | wall_picker.zig 6 | 49 | wall_picker.zig 6 |
| 50 | wall_pump.zig 45 | 50 | wall_pump.zig 45 |
| 51 | wall_test_harness.zig 1 | 51 | wall_test_harness.zig 1 |
| @@ -54,7 +54,7 @@ wall_test_layout.zig 2 | |||
| 54 | wall_test_picker.zig 4 | 54 | wall_test_picker.zig 4 |
| 55 | wall_test_pump.zig 9 | 55 | wall_test_pump.zig 9 |
| 56 | wall_test_wall.zig 7 | 56 | wall_test_wall.zig 7 |
| 57 | wallview.zig 64 | 57 | wallview.zig 63 |
| 58 | wasm_core.zig 5 | 58 | wasm_core.zig 5 |
| 59 | webhub_main.zig 4 | 59 | webhub_main.zig 4 |
| 60 | webhub.zig 29 | 60 | webhub.zig 29 |
docscheck.budget
| Old | New | ||
|---|---|---|---|
| @@ -50,7 +50,7 @@ server_sessions.zig 0 | |||
| 50 | wall_host.zig 0 | 50 | wall_host.zig 0 |
| 51 | wall_picker.zig 0 | 51 | wall_picker.zig 0 |
| 52 | wall_pump.zig 0 | 52 | wall_pump.zig 0 |
| 53 | wall_layout.zig 1432 | 53 | wall_layout.zig 890 |
| 54 | wall_test_harness.zig 0 | 54 | wall_test_harness.zig 0 |
| 55 | wall_test_host.zig 0 | 55 | wall_test_host.zig 0 |
| 56 | wall_test_picker.zig 0 | 56 | wall_test_picker.zig 0 |
src/tui/wall_layout.zig
| Old | New | ||
|---|---|---|---|
| @@ -190,99 +190,28 @@ pub fn relayout(w: Wall, sel: usize) void { | |||
| 190 | wv.paintDeadBarsLocked(w.liveTiles()); | 190 | wv.paintDeadBarsLocked(w.liveTiles()); |
| 191 | } | 191 | } |
| 192 | 192 | ||
| 193 | /// Rebuild the wall's tree from a saved sidecar, healing per leaf: each | 193 | /// The file half of the seed: null on a pipe, a missing sidecar, or bytes |
| 194 | /// saved leaf id takes the first wall tile whose label matches its | 194 | /// `seedLayout` refuses — and the wall then boots on today's default cut. |
| 195 | /// spelling, unmatched saved leaves collapse out, and unmatched wall tiles | 195 | pub fn seedSidecar(alloc: std.mem.Allocator, table: []const Host, shared: *Shared, entry_spelling: ?[]const u8) ?SeedPlan { |
| 196 | /// insert beside the first matched one. The saved root orientation wins | 196 | if (!shared.is_tty) return null; |
| 197 | /// over the aspect heuristic — `setRootOrient` is never called here. | 197 | const path = hosts.layoutPath(alloc) catch return null; |
| 198 | /// | 198 | defer alloc.free(path); |
| 199 | /// Pure of file I/O: bytes come in, the caller decides whether to feed | 199 | const bytes = loadLayout(alloc, path) orelse return null; |
| 200 | /// them. False on any malformation, zero matches, or allocation failure — | 200 | defer alloc.free(bytes); |
| 201 | /// the caller builds today's default tree and the sidecar is ignored. | 201 | return seedLayout(alloc, table, shared, bytes, entry_spelling); |
| 202 | pub fn restoreLayout( | 202 | } |
| 203 | alloc: std.mem.Allocator, | ||
| 204 | resolved: []const Resolved, | ||
| 205 | shared: *Shared, | ||
| 206 | bytes: []const u8, | ||
| 207 | focus_out: *?u8, | ||
| 208 | ) bool { | ||
| 209 | var parsed = layout.parse(alloc, bytes) orelse return false; | ||
| 210 | // Every early-false path deinits parsed; the success path moves the | ||
| 211 | // tree out and deinits only the spellings. | ||
| 212 | |||
| 213 | const n_saved = parsed.spellings.items.len; | ||
| 214 | const map = alloc.alloc(?u8, n_saved) catch { | ||
| 215 | parsed.deinit(alloc); | ||
| 216 | return false; | ||
| 217 | }; | ||
| 218 | defer alloc.free(map); | ||
| 219 | |||
| 220 | var taken = alloc.alloc(bool, resolved.len) catch { | ||
| 221 | parsed.deinit(alloc); | ||
| 222 | return false; | ||
| 223 | }; | ||
| 224 | defer alloc.free(taken); | ||
| 225 | @memset(taken, false); | ||
| 226 | |||
| 227 | var any_match = false; | ||
| 228 | for (0..n_saved) |i| { | ||
| 229 | const spelling = parsed.spellings.items[i]; | ||
| 230 | map[i] = null; | ||
| 231 | for (resolved, 0..) |r, j| { | ||
| 232 | if (!taken[j] and std.mem.eql(u8, r.label, spelling)) { | ||
| 233 | taken[j] = true; | ||
| 234 | map[i] = @intCast(j); | ||
| 235 | any_match = true; | ||
| 236 | break; | ||
| 237 | } | ||
| 238 | } | ||
| 239 | } | ||
| 240 | |||
| 241 | // Zero matches: the sidecar describes a different wall entirely. | ||
| 242 | // Deinit parsed and return false so the caller builds the default tree. | ||
| 243 | if (!any_match) { | ||
| 244 | parsed.deinit(alloc); | ||
| 245 | return false; | ||
| 246 | } | ||
| 247 | |||
| 248 | // Map the saved focus through the spelling match before remapLeaves | ||
| 249 | // consumes map. A null here means the focused leaf did not survive | ||
| 250 | // (no wall tile matched its spelling) — the caller falls back to 0. | ||
| 251 | if (parsed.focus) |k| { | ||
| 252 | if (k < map.len) focus_out.* = map[k]; | ||
| 253 | } | ||
| 254 | |||
| 255 | parsed.tree.remapLeaves(map); | ||
| 256 | |||
| 257 | // Unmatched wall indices, ascending, insert beside the first matched | ||
| 258 | // tile — the same beside-focus placement the initial build uses. | ||
| 259 | const first_tile: u8 = if (map.len > 0) blk: { | ||
| 260 | var k: usize = 0; | ||
| 261 | while (k < map.len) : (k += 1) { | ||
| 262 | if (map[k] != null) break :blk map[k].?; | ||
| 263 | } | ||
| 264 | break :blk 0; | ||
| 265 | } else 0; | ||
| 266 | 203 | ||
| 267 | var next_tile: u8 = first_tile; | 204 | /// Every pane still waiting when the wall settles goes at once, in the |
| 268 | for (taken, 0..) |is_taken, j| { | 205 | /// ONE re-cut the caller owes when this answers true: after it, no tile |
| 269 | if (!is_taken) { | 206 | /// on the wall came from the file. |
| 270 | const new_id: u8 = @intCast(j); | 207 | pub fn collapsePending(w: Wall) bool { |
| 271 | parsed.tree.insert(next_tile, new_id) catch { | 208 | var took = false; |
| 272 | parsed.deinit(alloc); | 209 | for (w.liveTiles(), w.livePresent(), 0..) |*t, p, i| { |
| 273 | return false; | 210 | if (!p or !t.pending) continue; |
| 274 | }; | 211 | wv.vanishTile(w.liveTiles(), w.livePresent(), w.shared, i, null); |
| 275 | next_tile = new_id; | 212 | took = true; |
| 276 | } | ||
| 277 | } | 213 | } |
| 278 | 214 | return took; | |
| 279 | // Move the parsed tree into shared, deinit the old one first. | ||
| 280 | shared.tree.deinit(); | ||
| 281 | shared.tree = parsed.tree; | ||
| 282 | // The spellings are no longer needed — the tree holds only ids now. | ||
| 283 | for (parsed.spellings.items) |s| alloc.free(s); | ||
| 284 | parsed.spellings.deinit(alloc); | ||
| 285 | return true; | ||
| 286 | } | 215 | } |
| 287 | 216 | ||
| 288 | /// Every failure is the same null: a caller degrades the same way whatever | 217 | /// Every failure is the same null: a caller degrades the same way whatever |
| @@ -323,8 +252,8 @@ pub fn saveLayoutTo( | |||
| 323 | /// Resolves the sidecar path from env and delegates to `saveLayoutTo`. | 252 | /// Resolves the sidecar path from env and delegates to `saveLayoutTo`. |
| 324 | pub fn saveSidecar(w: Wall) void { | 253 | pub fn saveSidecar(w: Wall) void { |
| 325 | // A pipe has no stripes, so it has no layout worth remembering — and | 254 | // A pipe has no stripes, so it has no layout worth remembering — and |
| 326 | // a tree it saved would be a tree the next TERMINAL restores over the | 255 | // a tree it saved would be a tree the next TERMINAL seeds over the |
| 327 | // aspect rule. See `restoreSidecar` for the cost of the other half. | 256 | // aspect rule. `seedSidecar` refuses a pipe for the same reason. |
| 328 | if (!w.shared.is_tty) return; | 257 | if (!w.shared.is_tty) return; |
| 329 | const path = hosts.layoutPath(w.alloc) catch |err| { | 258 | const path = hosts.layoutPath(w.alloc) catch |err| { |
| 330 | std.debug.print("mux: wall layout not saved: {s}\n", .{@errorName(err)}); | 259 | std.debug.print("mux: wall layout not saved: {s}\n", .{@errorName(err)}); |
| @@ -523,59 +452,6 @@ fn seedAttempt( | |||
| 523 | return plan; | 452 | return plan; |
| 524 | } | 453 | } |
| 525 | 454 | ||
| 526 | /// The saved layout over the tiles the hosts turned out to have; null | ||
| 527 | /// when nothing matched. | ||
| 528 | pub fn restoreSidecar(w: Wall, focus_out: *?usize) ?void { | ||
| 529 | // Not on a pipe, and the cost is why. A restored tree is applied by | ||
| 530 | // `focusAnswer(recut = true)`, whose `relayout` flags `resize_pending` | ||
| 531 | // on EVERY present tile whether or not that tile's rect moved; each | ||
| 532 | // pump then sends `.resize`, and `Server.onResize` answers every one of | ||
| 533 | // them with `resyncSnapshot` — which has no "nothing changed" guard. | ||
| 534 | // Measured on a scripted `mux quic://...` whose state home already held | ||
| 535 | // a sidecar: a second full snapshot on the wire for a wall of one tile | ||
| 536 | // that was already the right shape. The CLAIM is innocent; it stopped | ||
| 537 | // sending a resize (see the pump's focus-claim block). | ||
| 538 | if (!w.shared.is_tty) return null; | ||
| 539 | const path = hosts.layoutPath(w.alloc) catch return null; | ||
| 540 | defer w.alloc.free(path); | ||
| 541 | const bytes = loadLayout(w.alloc, path) orelse return null; | ||
| 542 | defer w.alloc.free(bytes); | ||
| 543 | return restoreLayoutFrom(w, bytes, focus_out); | ||
| 544 | } | ||
| 545 | |||
| 546 | /// Pure of file I/O like `restoreLayout`: the chained id translations — | ||
| 547 | /// saved→dense, then dense→real — are what a test must reach. | ||
| 548 | pub fn restoreLayoutFrom(w: Wall, bytes: []const u8, focus_out: *?usize) ?void { | ||
| 549 | const n = wv.presentCount(w.livePresent()); | ||
| 550 | if (n == 0) return null; | ||
| 551 | const dense = w.alloc.alloc(Resolved, n) catch return null; | ||
| 552 | defer w.alloc.free(dense); | ||
| 553 | const remap = w.alloc.alloc(?u8, n) catch return null; | ||
| 554 | defer w.alloc.free(remap); | ||
| 555 | var di: usize = 0; | ||
| 556 | for (w.liveTiles(), w.livePresent(), 0..) |*t, p, ti| { | ||
| 557 | if (p) { | ||
| 558 | dense[di] = t.r; | ||
| 559 | remap[di] = @intCast(ti); | ||
| 560 | di += 1; | ||
| 561 | } | ||
| 562 | } | ||
| 563 | var dense_focus: ?u8 = null; | ||
| 564 | if (restoreLayout(w.alloc, dense, w.shared, bytes, &dense_focus)) { | ||
| 565 | // The tree's leaf ids are dense indices into `dense`; remap them | ||
| 566 | // to the real tile indices relayout reads, and the saved focus | ||
| 567 | // with them. | ||
| 568 | w.shared.tree.remapLeaves(remap); | ||
| 569 | if (dense_focus) |d| { | ||
| 570 | if (d < remap.len) { | ||
| 571 | if (remap[d]) |real| focus_out.* = real; | ||
| 572 | } | ||
| 573 | } | ||
| 574 | return {}; | ||
| 575 | } | ||
| 576 | return null; | ||
| 577 | } | ||
| 578 | |||
| 579 | /// The leaf a birth sits beside: `birthTile` inserts against a LEAF, and | 455 | /// The leaf a birth sits beside: `birthTile` inserts against a LEAF, and |
| 580 | /// the focus can be a hole. | 456 | /// the focus can be a hole. |
| 581 | pub fn anchorTile(present: []const bool, sel: usize) usize { | 457 | pub fn anchorTile(present: []const bool, sel: usize) usize { |
src/tui/wall_test_layout.zig
| Old | New | ||
|---|---|---|---|
| @@ -705,216 +705,6 @@ test "saveLayoutTo handles a vanished middle tile without panicking" { | |||
| 705 | try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#b") == null); | 705 | try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#b") == null); |
| 706 | } | 706 | } |
| 707 | 707 | ||
| 708 | test "restore: a saved beside pair comes back verbatim on a tall terminal" { | ||
| 709 | // Aspect at 40x30 would say stacked; the sidecar says beside and wins. | ||
| 710 | const alloc = std.testing.allocator; | ||
| 711 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 40, .rows = 30 }, .is_tty = false }; | ||
| 712 | shared.tree = layout.Tree.init(alloc); | ||
| 713 | shared.flat_alloc = alloc; | ||
| 714 | defer shared.tree.deinit(); | ||
| 715 | defer if (shared.last_flat) |*f| f.deinit(alloc); | ||
| 716 | defer if (shared.base_flat) |*f| f.deinit(alloc); | ||
| 717 | const resolved = [_]Resolved{ | ||
| 718 | .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#a", .session = "a" }, | ||
| 719 | .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#b", .session = "b" }, | ||
| 720 | }; | ||
| 721 | const bytes = "mux-layout 1\nbeside 0\n leaf 40 --sock /tmp/x#a\n leaf 39 --sock /tmp/x#b\n"; | ||
| 722 | var focus_out: ?u8 = null; | ||
| 723 | try std.testing.expect(wall_layout.restoreLayout(alloc, &resolved, &shared, bytes, &focus_out)); | ||
| 724 | const tiles = try alloc.alloc(Tile, 2); | ||
| 725 | defer alloc.free(tiles); | ||
| 726 | var present = [_]bool{ true, true }; | ||
| 727 | for (tiles, 0..) |*t, i| { | ||
| 728 | t.* = Tile{ | ||
| 729 | .r = resolved[i], | ||
| 730 | .rect = .{ .top = 0, .left = 0, .rows = 30, .cols = 40 }, | ||
| 731 | .shared = &shared, | ||
| 732 | .idx = i, | ||
| 733 | .wake_r = -1, | ||
| 734 | .wake_w = -1, | ||
| 735 | }; | ||
| 736 | } | ||
| 737 | wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0); | ||
| 738 | try std.testing.expectEqual(@as(u16, 0), tiles[1].rect.top); | ||
| 739 | try std.testing.expect(tiles[1].rect.left > 0); | ||
| 740 | } | ||
| 741 | |||
| 742 | test "restore: heals — unknown wall line inserted, lost leaf dropped" { | ||
| 743 | // Sidecar knows a, GONE and c side by side; the wall has a, c and NEW. | ||
| 744 | // Three saved leaves, not two: with two, dropping one collapses the | ||
| 745 | // container and every heal lands on the default cut, so the claim | ||
| 746 | // ("inserted BESIDE the match") could not be seen. | ||
| 747 | const alloc = std.testing.allocator; | ||
| 748 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; | ||
| 749 | shared.tree = layout.Tree.init(alloc); | ||
| 750 | shared.flat_alloc = alloc; | ||
| 751 | defer shared.tree.deinit(); | ||
| 752 | defer if (shared.last_flat) |*f| f.deinit(alloc); | ||
| 753 | defer if (shared.base_flat) |*f| f.deinit(alloc); | ||
| 754 | const resolved = [_]Resolved{ | ||
| 755 | .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#a", .session = "a" }, | ||
| 756 | .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#c", .session = "c" }, | ||
| 757 | .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#new", .session = "new" }, | ||
| 758 | }; | ||
| 759 | const bytes = "mux-layout 1\nbeside 0\n leaf 26 --sock /tmp/x#a\n" ++ | ||
| 760 | " leaf 26 --sock /tmp/x#gone\n leaf 26 --sock /tmp/x#c\n"; | ||
| 761 | var focus_out: ?u8 = null; | ||
| 762 | try std.testing.expect(wall_layout.restoreLayout(alloc, &resolved, &shared, bytes, &focus_out)); | ||
| 763 | try std.testing.expectEqual(@as(usize, 3), shared.tree.count()); | ||
| 764 | const flat = try shared.tree.flatten(alloc, 24, 80, wall_layout.wallFloors(3), null); | ||
| 765 | defer flat.deinit(alloc); | ||
| 766 | const a = flat.rectOf(0) orelse return error.TestUnexpectedResult; | ||
| 767 | const c = flat.rectOf(1) orelse return error.TestUnexpectedResult; | ||
| 768 | const new = flat.rectOf(2) orelse return error.TestUnexpectedResult; | ||
| 769 | // The saved axis survives the drop — a heal that rebuilt the default | ||
| 770 | // cut would stack these, giving them one left and three tops. | ||
| 771 | try std.testing.expectEqual(a.top, c.top); | ||
| 772 | try std.testing.expectEqual(a.top, new.top); | ||
| 773 | // NEW landed next to the match (a), not at either end by accident. | ||
| 774 | try std.testing.expect(a.left < new.left); | ||
| 775 | try std.testing.expect(new.left < c.left); | ||
| 776 | } | ||
| 777 | |||
| 778 | test "restore: zero matches or garbage degrade to false" { | ||
| 779 | const alloc = std.testing.allocator; | ||
| 780 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; | ||
| 781 | shared.tree = layout.Tree.init(alloc); | ||
| 782 | shared.flat_alloc = alloc; | ||
| 783 | defer shared.tree.deinit(); | ||
| 784 | defer if (shared.last_flat) |*f| f.deinit(alloc); | ||
| 785 | defer if (shared.base_flat) |*f| f.deinit(alloc); | ||
| 786 | const resolved = [_]Resolved{ | ||
| 787 | .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#a", .session = "a" }, | ||
| 788 | }; | ||
| 789 | var focus_out: ?u8 = null; | ||
| 790 | try std.testing.expect(!wall_layout.restoreLayout(alloc, &resolved, &shared, "not a sidecar", &focus_out)); | ||
| 791 | const nomatch = "mux-layout 1\nleaf 0 --sock /nowhere#z\n"; | ||
| 792 | try std.testing.expect(!wall_layout.restoreLayout(alloc, &resolved, &shared, nomatch, &focus_out)); | ||
| 793 | } | ||
| 794 | |||
| 795 | test "restore: duplicate spellings pair positionally" { | ||
| 796 | // Wall shows one host twice: same spelling on both lines. Saved | ||
| 797 | // leaves 0 and 1 share it; leaf 0 takes wall 0, leaf 1 takes wall 1. | ||
| 798 | const alloc = std.testing.allocator; | ||
| 799 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; | ||
| 800 | shared.tree = layout.Tree.init(alloc); | ||
| 801 | shared.flat_alloc = alloc; | ||
| 802 | defer shared.tree.deinit(); | ||
| 803 | defer if (shared.last_flat) |*f| f.deinit(alloc); | ||
| 804 | defer if (shared.base_flat) |*f| f.deinit(alloc); | ||
| 805 | const resolved = [_]Resolved{ | ||
| 806 | .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#dup", .session = "dup" }, | ||
| 807 | .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#dup", .session = "dup" }, | ||
| 808 | }; | ||
| 809 | // Lopsided on purpose: with near-equal weights a mispairing produces | ||
| 810 | // near-equal rects and hides in the leaf count. | ||
| 811 | const bytes = "mux-layout 1\nbeside 0\n leaf 59 --sock /tmp/x#dup\n leaf 20 --sock /tmp/x#dup\n"; | ||
| 812 | var focus_out: ?u8 = null; | ||
| 813 | try std.testing.expect(wall_layout.restoreLayout(alloc, &resolved, &shared, bytes, &focus_out)); | ||
| 814 | try std.testing.expectEqual(@as(usize, 2), shared.tree.count()); | ||
| 815 | // Pairing is the whole claim and it IS observable: saved leaf 0 owns | ||
| 816 | // the left, wide slot, so it must be wall tile 0. Paired the other | ||
| 817 | // way round the count is still 2 and the tiles have swapped screens. | ||
| 818 | const flat = try shared.tree.flatten(alloc, 24, 80, wall_layout.wallFloors(2), null); | ||
| 819 | defer flat.deinit(alloc); | ||
| 820 | const t0 = flat.rectOf(0) orelse return error.TestUnexpectedResult; | ||
| 821 | const t1 = flat.rectOf(1) orelse return error.TestUnexpectedResult; | ||
| 822 | try std.testing.expect(t0.left < t1.left); | ||
| 823 | try std.testing.expect(t0.cols > t1.cols); | ||
| 824 | } | ||
| 825 | |||
| 826 | test "restoreLayoutFrom: dense sidecar indices land on the real tile indices" { | ||
| 827 | // Tile 1 was forgotten, so the dense array is 0,2,3 and the two index | ||
| 828 | // spaces disagree. With no hole they coincide and the second remap is | ||
| 829 | // untestable by construction — which is how a tile could be handed the | ||
| 830 | // rect flattened for its neighbour and no test would say so. | ||
| 831 | const alloc = std.testing.allocator; | ||
| 832 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; | ||
| 833 | shared.tree = layout.Tree.init(alloc); | ||
| 834 | shared.flat_alloc = alloc; | ||
| 835 | defer shared.tree.deinit(); | ||
| 836 | defer if (shared.last_flat) |*f| f.deinit(alloc); | ||
| 837 | defer if (shared.base_flat) |*f| f.deinit(alloc); | ||
| 838 | const labels = [_][]const u8{ | ||
| 839 | "--sock /tmp/x#a", | ||
| 840 | "--sock /tmp/x#hole", | ||
| 841 | "--sock /tmp/x#c", | ||
| 842 | "--sock /tmp/x#d", | ||
| 843 | }; | ||
| 844 | var tiles: [4]Tile = undefined; | ||
| 845 | for (&tiles, 0..) |*t, i| { | ||
| 846 | t.* = .{ | ||
| 847 | .r = .{ .target = .{ .sock = "/tmp/x" }, .label = labels[i], .session = "" }, | ||
| 848 | .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, | ||
| 849 | .shared = &shared, | ||
| 850 | .idx = i, | ||
| 851 | .wake_r = -1, | ||
| 852 | .wake_w = -1, | ||
| 853 | }; | ||
| 854 | } | ||
| 855 | var present = [_]bool{ true, false, true, true }; | ||
| 856 | // Three unequal weights over 80 columns less two rails: 40+20+18 = 78, | ||
| 857 | // so each tile's width names which saved leaf it was given. | ||
| 858 | const bytes = "mux-layout 1\nbeside 0\n leaf 40 --sock /tmp/x#a\n" ++ | ||
| 859 | " leaf 20 --sock /tmp/x#c\n leaf 18 --sock /tmp/x#d\n"; | ||
| 860 | var restored_focus: ?usize = null; | ||
| 861 | wall_layout.restoreLayoutFrom(fixture.wallLive(alloc, &tiles, &present, 4, &shared), bytes, &restored_focus) orelse | ||
| 862 | return error.TestUnexpectedResult; | ||
| 863 | const flat = try shared.tree.flatten(alloc, 24, 80, wall_layout.wallFloors(3), null); | ||
| 864 | defer flat.deinit(alloc); | ||
| 865 | try std.testing.expectEqual(@as(?layout.Rect, null), flat.rectOf(1)); | ||
| 866 | const a = flat.rectOf(0) orelse return error.TestUnexpectedResult; | ||
| 867 | const c = flat.rectOf(2) orelse return error.TestUnexpectedResult; | ||
| 868 | const d = flat.rectOf(3) orelse return error.TestUnexpectedResult; | ||
| 869 | try std.testing.expectEqual(@as(u16, 40), a.cols); | ||
| 870 | try std.testing.expectEqual(@as(u16, 20), c.cols); | ||
| 871 | try std.testing.expectEqual(@as(u16, 18), d.cols); | ||
| 872 | try std.testing.expect(a.left < c.left); | ||
| 873 | try std.testing.expect(c.left < d.left); | ||
| 874 | } | ||
| 875 | |||
| 876 | test "restore: focus_out maps the saved focus through the spelling match" { | ||
| 877 | // Saved leaf 1 matches wall tile 1 (b) → focus_out == 1. Saved leaf 1 | ||
| 878 | // matches NO wall tile (gone) → focus_out stays null. | ||
| 879 | const alloc = std.testing.allocator; | ||
| 880 | |||
| 881 | // Matched: focus 1 → tile 1. | ||
| 882 | { | ||
| 883 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; | ||
| 884 | shared.tree = layout.Tree.init(alloc); | ||
| 885 | shared.flat_alloc = alloc; | ||
| 886 | defer shared.tree.deinit(); | ||
| 887 | defer if (shared.last_flat) |*f| f.deinit(alloc); | ||
| 888 | defer if (shared.base_flat) |*f| f.deinit(alloc); | ||
| 889 | const resolved = [_]Resolved{ | ||
| 890 | .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#a", .session = "a" }, | ||
| 891 | .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#b", .session = "b" }, | ||
| 892 | }; | ||
| 893 | const bytes = "mux-layout 1\nbeside 0\n leaf 40 --sock /tmp/x#a\n leaf 39 --sock /tmp/x#b\nfocus 1\n"; | ||
| 894 | var focus_out: ?u8 = null; | ||
| 895 | try std.testing.expect(wall_layout.restoreLayout(alloc, &resolved, &shared, bytes, &focus_out)); | ||
| 896 | try std.testing.expectEqual(@as(?u8, 1), focus_out); | ||
| 897 | } | ||
| 898 | |||
| 899 | // Unmatched: saved leaf 1 (gone) has no wall tile → focus_out null. | ||
| 900 | { | ||
| 901 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; | ||
| 902 | shared.tree = layout.Tree.init(alloc); | ||
| 903 | shared.flat_alloc = alloc; | ||
| 904 | defer shared.tree.deinit(); | ||
| 905 | defer if (shared.last_flat) |*f| f.deinit(alloc); | ||
| 906 | defer if (shared.base_flat) |*f| f.deinit(alloc); | ||
| 907 | const resolved = [_]Resolved{ | ||
| 908 | .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#a", .session = "a" }, | ||
| 909 | .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#new", .session = "new" }, | ||
| 910 | }; | ||
| 911 | const bytes = "mux-layout 1\nbeside 0\n leaf 40 --sock /tmp/x#a\n leaf 39 --sock /tmp/x#gone\nfocus 1\n"; | ||
| 912 | var focus_out: ?u8 = null; | ||
| 913 | try std.testing.expect(wall_layout.restoreLayout(alloc, &resolved, &shared, bytes, &focus_out)); | ||
| 914 | try std.testing.expectEqual(@as(?u8, null), focus_out); | ||
| 915 | } | ||
| 916 | } | ||
| 917 | |||
| 918 | test "layout sidecar: save round-trips through load; a missing file is null" { | 708 | test "layout sidecar: save round-trips through load; a missing file is null" { |
| 919 | const alloc = std.testing.allocator; | 709 | const alloc = std.testing.allocator; |
| 920 | var tmp = try TmpDir.make(); | 710 | var tmp = try TmpDir.make(); |
| @@ -1171,3 +961,118 @@ test "seed: the entry tile's own pane is not pending; a sidecar that does not kn | |||
| 1171 | try std.testing.expectEqual(@as(?wall_layout.SeedPane, null), plan2.panes[0]); | 961 | try std.testing.expectEqual(@as(?wall_layout.SeedPane, null), plan2.panes[0]); |
| 1172 | try std.testing.expectEqual(@as(usize, 4), shared.tree.count()); | 962 | try std.testing.expectEqual(@as(usize, 4), shared.tree.count()); |
| 1173 | } | 963 | } |
| 964 | |||
| 965 | test "collapse: every pane no host claimed goes in ONE re-cut, and the panes that bound keep theirs" { | ||
| 966 | const alloc = std.testing.allocator; | ||
| 967 | var shared: Shared = undefined; | ||
| 968 | seedShared(alloc, &shared); | ||
| 969 | defer shared.tree.deinit(); | ||
| 970 | defer if (shared.last_flat) |*f| f.deinit(alloc); | ||
| 971 | defer if (shared.base_flat) |*f| f.deinit(alloc); | ||
| 972 | var tiles: [4]Tile = undefined; | ||
| 973 | var present = [_]bool{false} ** 4; | ||
| 974 | try shared.tree.addFirst(0); | ||
| 975 | try shared.tree.insert(0, 1); | ||
| 976 | try shared.tree.insert(1, 2); | ||
| 977 | // Three seeded panes; pane 1 bound (a live session now), 0 and 2 | ||
| 978 | // never claimed. Off-origin rects arrive from the relayout below. | ||
| 979 | for (0..3) |i| { | ||
| 980 | try wv.seedTile(&tiles[i], .{ | ||
| 981 | .target = .{ .sock = "/tmp/h0.sock" }, | ||
| 982 | .label = try alloc.dupe(u8, "--sock /tmp/h0.sock#x"), | ||
| 983 | .session = try alloc.dupe(u8, "x"), | ||
| 984 | }, .{ .top = 0, .left = 0, .rows = 0, .cols = 0 }, &shared, i, 0); | ||
| 985 | present[i] = true; | ||
| 986 | } | ||
| 987 | defer for (tiles[0..3]) |*t| { | ||
| 988 | alloc.free(t.r.label); | ||
| 989 | alloc.free(t.r.session); | ||
| 990 | if (t.wake_r >= 0) std.posix.close(t.wake_r); | ||
| 991 | if (t.wake_w >= 0) std.posix.close(t.wake_w); | ||
| 992 | }; | ||
| 993 | tiles[1].pending = false; | ||
| 994 | const w = fixture.wallLive(alloc, &tiles, &present, 3, &shared); | ||
| 995 | wall_layout.relayout(w, 1); | ||
| 996 | const kept = tiles[1].rect; | ||
| 997 | const gen = shared.repaint_gen.load(.acquire); | ||
| 998 | |||
| 999 | try std.testing.expect(wall_layout.collapsePending(w)); | ||
| 1000 | wall_layout.relayout(w, 1); | ||
| 1001 | |||
| 1002 | // One relayout, both unclaimed panes gone, the bound pane grew to the | ||
| 1003 | // whole screen and after the settle no tile came from the file. | ||
| 1004 | try std.testing.expectEqual(gen + 1, shared.repaint_gen.load(.acquire)); | ||
| 1005 | try std.testing.expect(!present[0] and present[1] and !present[2]); | ||
| 1006 | try std.testing.expect(tiles[1].rect.rows > kept.rows); | ||
| 1007 | for (tiles[0..3], present[0..3]) |t, p| { | ||
| 1008 | if (p) try std.testing.expect(!t.pending); | ||
| 1009 | } | ||
| 1010 | // Nothing left waiting: a second collapse has nothing to take. | ||
| 1011 | try std.testing.expect(!wall_layout.collapsePending(w)); | ||
| 1012 | } | ||
| 1013 | |||
| 1014 | test "collapse: a collapsed pane hands its digit straight back" { | ||
| 1015 | const alloc = std.testing.allocator; | ||
| 1016 | var shared: Shared = undefined; | ||
| 1017 | seedShared(alloc, &shared); | ||
| 1018 | defer shared.tree.deinit(); | ||
| 1019 | var tiles: [2]Tile = undefined; | ||
| 1020 | var present = [_]bool{false} ** 2; | ||
| 1021 | try shared.tree.addFirst(0); | ||
| 1022 | try wv.seedTile(&tiles[0], .{ | ||
| 1023 | .target = .{ .sock = "/tmp/h0.sock" }, | ||
| 1024 | .label = try alloc.dupe(u8, "--sock /tmp/h0.sock#gone"), | ||
| 1025 | .session = try alloc.dupe(u8, "gone"), | ||
| 1026 | }, .{ .top = 0, .left = 0, .rows = 0, .cols = 0 }, &shared, 0, 0); | ||
| 1027 | present[0] = true; | ||
| 1028 | defer { | ||
| 1029 | alloc.free(tiles[0].r.label); | ||
| 1030 | alloc.free(tiles[0].r.session); | ||
| 1031 | if (tiles[0].wake_r >= 0) std.posix.close(tiles[0].wake_r); | ||
| 1032 | if (tiles[0].wake_w >= 0) std.posix.close(tiles[0].wake_w); | ||
| 1033 | } | ||
| 1034 | const w = fixture.wallLive(alloc, &tiles, &present, 1, &shared); | ||
| 1035 | try std.testing.expect(wall_layout.collapsePending(w)); | ||
| 1036 | // `freeSlot` wants `!present and pump_done`: the seed stored the | ||
| 1037 | // second half precisely so this digit is not burned for the wall's | ||
| 1038 | // life - no pump ever held the slot. | ||
| 1039 | try std.testing.expectEqual(@as(?usize, 0), wv.freeSlot(&tiles, &present)); | ||
| 1040 | } | ||
| 1041 | |||
| 1042 | test "a wall left before its hosts answered saves the shape it was given" { | ||
| 1043 | const alloc = std.testing.allocator; | ||
| 1044 | var shared: Shared = undefined; | ||
| 1045 | seedShared(alloc, &shared); | ||
| 1046 | defer shared.tree.deinit(); | ||
| 1047 | var tiles: [2]Tile = undefined; | ||
| 1048 | var present = [_]bool{ true, true }; | ||
| 1049 | try shared.tree.addFirst(0); | ||
| 1050 | try shared.tree.insert(0, 1); | ||
| 1051 | for (0..2) |i| { | ||
| 1052 | try wv.seedTile(&tiles[i], .{ | ||
| 1053 | .target = .{ .sock = "/tmp/h0.sock" }, | ||
| 1054 | .label = if (i == 0) | ||
| 1055 | try alloc.dupe(u8, "--sock /tmp/h0.sock#a") | ||
| 1056 | else | ||
| 1057 | try alloc.dupe(u8, "--sock /tmp/h0.sock#b"), | ||
| 1058 | .session = try alloc.dupe(u8, if (i == 0) "a" else "b"), | ||
| 1059 | }, .{ .top = 0, .left = 0, .rows = 12, .cols = 100 }, &shared, i, 0); | ||
| 1060 | } | ||
| 1061 | defer for (tiles[0..2]) |*t| { | ||
| 1062 | alloc.free(t.r.label); | ||
| 1063 | alloc.free(t.r.session); | ||
| 1064 | if (t.wake_r >= 0) std.posix.close(t.wake_r); | ||
| 1065 | if (t.wake_w >= 0) std.posix.close(t.wake_w); | ||
| 1066 | }; | ||
| 1067 | var tmp = try TmpDir.make(); | ||
| 1068 | defer tmp.cleanup(); | ||
| 1069 | var pbuf: [256]u8 = undefined; | ||
| 1070 | const path = try std.fmt.bufPrint(&pbuf, "{s}/layout", .{tmp.path()}); | ||
| 1071 | // Detaching inside the settle window: the pending panes' spellings go | ||
| 1072 | // back out verbatim, so a quick in-and-out does not erode the sidecar. | ||
| 1073 | wall_layout.saveLayoutTo(alloc, path, &tiles, &present, &shared); | ||
| 1074 | const bytes = wall_layout.loadLayout(alloc, path) orelse return error.NothingSaved; | ||
| 1075 | defer alloc.free(bytes); | ||
| 1076 | try std.testing.expect(std.mem.indexOf(u8, bytes, "--sock /tmp/h0.sock#a") != null); | ||
| 1077 | try std.testing.expect(std.mem.indexOf(u8, bytes, "--sock /tmp/h0.sock#b") != null); | ||
| 1078 | } | ||
src/tui/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -963,7 +963,7 @@ pub fn presentCount(present: []const bool) usize { | |||
| 963 | 963 | ||
| 964 | /// The lowest slot a new tile may take back: off the wall AND its pump | 964 | /// The lowest slot a new tile may take back: off the wall AND its pump |
| 965 | /// returned. `Tile.pump_done` is why both halves are needed. | 965 | /// returned. `Tile.pump_done` is why both halves are needed. |
| 966 | fn freeSlot(tiles: []const Tile, present: []const bool) ?usize { | 966 | pub fn freeSlot(tiles: []const Tile, present: []const bool) ?usize { |
| 967 | for (tiles, present, 0..) |*t, p, i| { | 967 | for (tiles, present, 0..) |*t, p, i| { |
| 968 | if (!p and t.pump_done.load(.acquire)) return i; | 968 | if (!p and t.pump_done.load(.acquire)) return i; |
| 969 | } | 969 | } |
| @@ -1644,16 +1644,45 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 1644 | var shared = Shared{ .out_fd = stdout_fd, .size = size, .is_tty = is_tty }; | 1644 | var shared = Shared{ .out_fd = stdout_fd, .size = size, .is_tty = is_tty }; |
| 1645 | shared.tree = layout.Tree.init(alloc); | 1645 | shared.tree = layout.Tree.init(alloc); |
| 1646 | shared.flat_alloc = alloc; | 1646 | shared.flat_alloc = alloc; |
| 1647 | // The wall starts with the tile the user asked for and NOTHING else: | 1647 | const env_sock = std.posix.getenv(proto.sock_env); |
| 1648 | // every other tile arrives from a host's own list, so the sidecar is | 1648 | const env_session = std.posix.getenv(proto.session_env); |
| 1649 | // restored later, over the tiles the hosts turn out to have. | 1649 | // The host table before the first flatten, because the seed matches |
| 1650 | // saved leaves against these spellings. Allocated at CAPACITY: a | ||
| 1651 | // poller thread holds its `*Host` for the wall's whole life, so the | ||
| 1652 | // array may never move when the picker's `a` adds a host to it. | ||
| 1653 | const host_table = try alloc.alloc(Host, max_tiles); | ||
| 1654 | var hosts_live: usize = @min(host_specs.len, max_tiles); | ||
| 1655 | for (host_table[0..hosts_live], host_specs[0..hosts_live]) |*h, spec| { | ||
| 1656 | h.* = .{ | ||
| 1657 | .spec = spec, | ||
| 1658 | .shared = &shared, | ||
| 1659 | .self_name = selfSession(spec.target, env_sock, env_session), | ||
| 1660 | }; | ||
| 1661 | } | ||
| 1650 | const has_entry = entry.pre != null and entry.entry_host != null; | 1662 | const has_entry = entry.pre != null and entry.entry_host != null; |
| 1651 | if (has_entry) shared.tree.addFirst(0) catch return 2; | 1663 | var entry_name: []const u8 = ""; |
| 1664 | var entry_label: ?[]const u8 = null; | ||
| 1665 | if (has_entry) { | ||
| 1666 | // Duped, though argv outlives the process: a tile that ends frees | ||
| 1667 | // these two when its digit is taken back. | ||
| 1668 | entry_name = try alloc.dupe(u8, proto.resolveName(entry.entry_session)); | ||
| 1669 | entry_label = try tileLabel(alloc, host_specs[entry.entry_host.?].target, entry_name); | ||
| 1670 | } | ||
| 1671 | // The wall starts with the cut the sidecar remembers: its leaves are | ||
| 1672 | // pending panes the polls bind in place or the settle collapses, so | ||
| 1673 | // the first paint is the saved shape, not a placeholder to re-cut. | ||
| 1674 | var seed_plan: ?wall_layout.SeedPlan = if (measured != null) | ||
| 1675 | wall_layout.seedSidecar(alloc, host_table[0..hosts_live], &shared, entry_label) | ||
| 1676 | else | ||
| 1677 | null; | ||
| 1678 | const seeded = seed_plan != null; | ||
| 1679 | if (seed_plan == null and has_entry) shared.tree.addFirst(0) catch return 2; | ||
| 1680 | const boot_tiles: usize = if (seed_plan) |pl| pl.panes.len else @intFromBool(has_entry); | ||
| 1652 | const init_flat = shared.tree.flatten( | 1681 | const init_flat = shared.tree.flatten( |
| 1653 | alloc, | 1682 | alloc, |
| 1654 | size.rows, | 1683 | size.rows, |
| 1655 | size.cols, | 1684 | size.cols, |
| 1656 | wall_layout.wallFloors(@intFromBool(has_entry)), | 1685 | wall_layout.wallFloors(boot_tiles), |
| 1657 | null, | 1686 | null, |
| 1658 | ) catch { | 1687 | ) catch { |
| 1659 | std.debug.print("mux: terminal too small\n", .{}); | 1688 | std.debug.print("mux: terminal too small\n", .{}); |
| @@ -1761,21 +1790,15 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 1761 | // re-sliced wherever `hosts_live` grows — the picker's `a` is the only | 1790 | // re-sliced wherever `hosts_live` grows — the picker's `a` is the only |
| 1762 | // thing that does. | 1791 | // thing that does. |
| 1763 | var w: Wall = .{ .alloc = alloc, .tiles = tiles, .present = present, .live = &live, .shared = &shared }; | 1792 | var w: Wall = .{ .alloc = alloc, .tiles = tiles, .present = present, .live = &live, .shared = &shared }; |
| 1764 | const env_sock = std.posix.getenv(proto.sock_env); | 1793 | w.hosts = host_table[0..hosts_live]; |
| 1765 | const env_session = std.posix.getenv(proto.session_env); | ||
| 1766 | if (has_entry) { | 1794 | if (has_entry) { |
| 1767 | const hi = entry.entry_host.?; | 1795 | const hi = entry.entry_host.?; |
| 1768 | const target = host_specs[hi].target; | 1796 | const target = host_specs[hi].target; |
| 1769 | // Duped, though argv outlives the process: a tile that ends frees | ||
| 1770 | // these two when its digit is taken back, and a free of argv would | ||
| 1771 | // be the birth after the entry tile's exit, not this line, that | ||
| 1772 | // crashed. | ||
| 1773 | const name = try alloc.dupe(u8, proto.resolveName(entry.entry_session)); | ||
| 1774 | const rect = init_flat.rectOf(0) orelse return 2; | 1797 | const rect = init_flat.rectOf(0) orelse return 2; |
| 1775 | try initTile(&tiles[0], .{ | 1798 | try initTile(&tiles[0], .{ |
| 1776 | .target = target, | 1799 | .target = target, |
| 1777 | .label = try tileLabel(alloc, target, name), | 1800 | .label = entry_label.?, |
| 1778 | .session = name, | 1801 | .session = entry_name, |
| 1779 | .agent = entry.agent, | 1802 | .agent = entry.agent, |
| 1780 | }, rect, &shared, 0, .fresh); | 1803 | }, rect, &shared, 0, .fresh); |
| 1781 | tiles[0].host = hi; | 1804 | tiles[0].host = hi; |
| @@ -1799,34 +1822,41 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 1799 | // the dial, and the attach already carries the terminal's size. | 1822 | // the dial, and the attach already carries the terminal's size. |
| 1800 | tiles[0].claim_pending.store(true, .release); | 1823 | tiles[0].claim_pending.store(true, .release); |
| 1801 | } | 1824 | } |
| 1825 | if (seed_plan) |*pl| { | ||
| 1826 | for (pl.panes, 0..) |mp, i| { | ||
| 1827 | const pane = mp orelse continue; | ||
| 1828 | const rect = init_flat.rectOf(@intCast(i)) orelse layout.Rect{ .top = 0, .left = 0, .rows = 0, .cols = 0 }; | ||
| 1829 | try seedTile(&tiles[i], .{ | ||
| 1830 | .target = host_table[pane.host].spec.target, | ||
| 1831 | .label = pane.label, | ||
| 1832 | .session = pane.session, | ||
| 1833 | }, rect, &shared, i, pane.host); | ||
| 1834 | present[i] = true; | ||
| 1835 | } | ||
| 1836 | live = pl.panes.len; | ||
| 1837 | // The saved focus, unless the user is already typing into the | ||
| 1838 | // entry tile - a focus record must not move them off it. | ||
| 1839 | if (!has_entry) shared.sel = pl.focus orelse (wall_layout.firstPresent(present[0..live]) orelse 0); | ||
| 1840 | // The names moved into the tiles above; only the array goes. | ||
| 1841 | alloc.free(pl.panes); | ||
| 1842 | seed_plan = null; | ||
| 1843 | } | ||
| 1802 | // A one-tile wall owns every row and draws no label bar; two or more | 1844 | // A one-tile wall owns every row and draws no label bar; two or more |
| 1803 | // tiles each lose their top row to one. Set before the pumps start so | 1845 | // tiles each lose their top row to one. Set before the pumps start so |
| 1804 | // `viewRows` is right on the first attach. | 1846 | // `viewRows` is right on the first attach. |
| 1805 | shared.label_rows = if (w.live.* > 1) 1 else 0; | 1847 | shared.label_rows = if (w.live.* > 1) 1 else 0; |
| 1806 | for (tiles[0..live]) |*t| spawnPump(t); | 1848 | for (tiles[0..live]) |*t| spawnPump(t); |
| 1807 | // A wall with no tile yet paints its one line rather than nothing: a | 1849 | // A wall with no tile yet paints its one line rather than nothing (a |
| 1808 | // blank terminal with no cursor reads as hung, and the hosts are up to | 1850 | // blank terminal with no cursor reads as hung), and a seeded wall |
| 1809 | // a poll away from having anything to show. | 1851 | // paints its remembered cut: rails and waiting bars have no pump. |
| 1810 | if (w.live.* == 0) wall_layout.relayout(w, 0); | 1852 | if (w.live.* == 0 or seeded) wall_layout.relayout(w, shared.sel); |
| 1811 | 1853 | ||
| 1812 | // One poller per host, all of them at once and none of them on this | 1854 | // One poller per host, all of them at once and none of them on this |
| 1813 | // thread: the user asked for one session and must not be held on | 1855 | // thread: the user asked for one session and must not be held on |
| 1814 | // another host's ssh to see it. | 1856 | // another host's ssh to see it. |
| 1815 | // | ||
| 1816 | // Allocated at CAPACITY for the tiles' reason: a poller thread holds | ||
| 1817 | // its `*Host` for the wall's whole life, so the array may never move | ||
| 1818 | // when the picker's `a` adds a host to it. | ||
| 1819 | const host_table = try alloc.alloc(Host, max_tiles); | ||
| 1820 | var hosts_live: usize = @min(host_specs.len, max_tiles); | ||
| 1821 | w.hosts = host_table[0..hosts_live]; | ||
| 1822 | var over_buf: [64]u8 = undefined; | 1857 | var over_buf: [64]u8 = undefined; |
| 1823 | if (wall_host.hostsOverCapacity(&over_buf, host_specs.len)) |said| setNotice(&shared, said); | 1858 | if (wall_host.hostsOverCapacity(&over_buf, host_specs.len)) |said| setNotice(&shared, said); |
| 1824 | for (host_table[0..hosts_live], host_specs[0..hosts_live]) |*h, spec| { | 1859 | for (host_table[0..hosts_live]) |*h| { |
| 1825 | h.* = .{ | ||
| 1826 | .spec = spec, | ||
| 1827 | .shared = &shared, | ||
| 1828 | .self_name = selfSession(spec.target, env_sock, env_session), | ||
| 1829 | }; | ||
| 1830 | // Not without a terminal (`headless`). A poller here would turn | 1860 | // Not without a terminal (`headless`). A poller here would turn |
| 1831 | // every scripted `mux --sock S` into a wall of whatever that daemon | 1861 | // every scripted `mux --sock S` into a wall of whatever that daemon |
| 1832 | // happens to be running: measured on a piped attach to a daemon | 1862 | // happens to be running: measured on a piped attach to a daemon |
| @@ -1883,16 +1913,15 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 1883 | // on the normal screen once the terminal is back. | 1913 | // on the normal screen once the terminal is back. |
| 1884 | var exit_code: u8 = 0; | 1914 | var exit_code: u8 = 0; |
| 1885 | var exit_msg: ?[]const u8 = null; | 1915 | var exit_msg: ?[]const u8 = null; |
| 1886 | // The sidecar is restored ONCE, over the tiles the hosts turned out to | 1916 | // The wall SETTLES once: every opening host has answered, or 2s - the |
| 1887 | // have: at the latest 2s in, so a host that never answers cannot hold | 1917 | // poll's own dial budget - so a host that never answers cannot hold |
| 1888 | // the user on an unlaid-out wall, and as soon as every host has | 1918 | // the seeded panes on screen forever. The settle owes one re-cut, for |
| 1889 | // reported when they are quick. | 1919 | // the panes nobody claimed. |
| 1890 | const restore_due: i64 = std.time.milliTimestamp() + 2000; | 1920 | const settle_due: i64 = std.time.milliTimestamp() + 2000; |
| 1891 | var restore_tried = false; | 1921 | var settled = false; |
| 1892 | var restore_ok = false; | ||
| 1893 | // Only the hosts the wall OPENED with are waited for. One added in the | 1922 | // Only the hosts the wall OPENED with are waited for. One added in the |
| 1894 | // picker arrives long after this, on a wall the user is already | 1923 | // picker arrives long after this, on a wall the user is already |
| 1895 | // looking at, and holding the restore for it would relay their panes | 1924 | // looking at, and holding the settle for it would relay their panes |
| 1896 | // under their hands. | 1925 | // under their hands. |
| 1897 | const opening_hosts = hosts_live; | 1926 | const opening_hosts = hosts_live; |
| 1898 | // The aspect heuristic is the fallback for a wall with no saved tree, | 1927 | // The aspect heuristic is the fallback for a wall with no saved tree, |
| @@ -2015,7 +2044,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 2015 | // news and arrives at once, while a list is up to a poll behind. | 2044 | // news and arrives at once, while a list is up to a poll behind. |
| 2016 | // Reading the list first would vanish the tile the exit code is on. | 2045 | // Reading the list first would vanish the tile the exit code is on. |
| 2017 | const host_news = wall_host.applyReadyLists(w); | 2046 | const host_news = wall_host.applyReadyLists(w); |
| 2018 | if (!restore_tried) { | 2047 | if (!settled) { |
| 2019 | var all_reported = true; | 2048 | var all_reported = true; |
| 2020 | for (host_table[0..opening_hosts]) |*h| { | 2049 | for (host_table[0..opening_hosts]) |*h| { |
| 2021 | if (!h.applied) { | 2050 | if (!h.applied) { |
| @@ -2023,19 +2052,18 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 2023 | break; | 2052 | break; |
| 2024 | } | 2053 | } |
| 2025 | } | 2054 | } |
| 2026 | if (all_reported or std.time.milliTimestamp() >= restore_due) { | 2055 | if (all_reported or std.time.milliTimestamp() >= settle_due) { |
| 2027 | restore_tried = true; | 2056 | settled = true; |
| 2028 | var saved_focus: ?usize = null; | 2057 | if (wall_layout.collapsePending(w)) { |
| 2029 | if (w.live.* > 0 and wall_layout.restoreSidecar(w, &saved_focus) != null) { | 2058 | // The focus may have sat on a collapsed pane; the wall |
| 2030 | restore_ok = true; | 2059 | // owes the tile it ends up with a `setFocus`. |
| 2031 | // The entry tile is the one the user is already typing | 2060 | if ((shared.sel >= live or !present[shared.sel]) and presentCount(present[0..live]) > 0) |
| 2032 | // into; a saved focus record must not move them off it. | 2061 | setFocus(tiles[0..live], &shared, wall_layout.firstPresent(present[0..live]) orelse 0); |
| 2033 | const to = if (has_entry) shared.sel else (saved_focus orelse shared.sel); | 2062 | wall_layout.relayout(w, shared.sel); |
| 2034 | focusAnswer(w, true, to); | ||
| 2035 | } | 2063 | } |
| 2036 | } | 2064 | } |
| 2037 | } | 2065 | } |
| 2038 | if (restore_tried and !restore_ok and !oriented and presentCount(present[0..live]) > 1) { | 2066 | if (!seeded and !oriented and presentCount(present[0..live]) > 1) { |
| 2039 | oriented = true; | 2067 | oriented = true; |
| 2040 | shared.paint_mu.lock(); | 2068 | shared.paint_mu.lock(); |
| 2041 | shared.tree.setRootOrient(wall_layout.rootOrient(shared.size)); | 2069 | shared.tree.setRootOrient(wall_layout.rootOrient(shared.size)); |
| @@ -2047,12 +2075,12 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 2047 | // list of machines. Once, so the Esc that closes it leaves the | 2075 | // list of machines. Once, so the Esc that closes it leaves the |
| 2048 | // one-line empty-wall text standing. | 2076 | // one-line empty-wall text standing. |
| 2049 | var picker_opened = false; | 2077 | var picker_opened = false; |
| 2050 | // `restore_tried` is the gate, not emptiness alone: it is the moment | 2078 | // `settled` is the gate, not emptiness alone: it is the moment |
| 2051 | // every opening host has answered (or 2s), and a wall still dialling | 2079 | // every opening host has answered (or 2s), and a wall still dialling |
| 2052 | // is not known to be empty. A popup flashed over a wall that is | 2080 | // is not known to be empty. A popup flashed over a wall that is |
| 2053 | // about to have tiles is one the user never asked for. | 2081 | // about to have tiles is one the user never asked for. |
| 2054 | switch (picker_auto.step( | 2082 | switch (picker_auto.step( |
| 2055 | shared.is_tty and restore_tried, | 2083 | shared.is_tty and settled, |
| 2056 | presentCount(present[0..live]) == 0, | 2084 | presentCount(present[0..live]) == 0, |
| 2057 | input.prefix.picking, | 2085 | input.prefix.picking, |
| 2058 | input.prefix.prompting, | 2086 | input.prefix.prompting, |
test/e2e_09_hosts.sh
| Old | New | ||
|---|---|---|---|
| @@ -511,11 +511,14 @@ ok "x on a wall ends the focused tile's session and no other, and that tile leav | |||
| 511 | # | 511 | # |
| 512 | # Two states of one host, each with its own witness: | 512 | # Two states of one host, each with its own witness: |
| 513 | # | 513 | # |
| 514 | # * down: NOTHING. The wall shows live sessions and nothing else, so a | 514 | # * down: NOTHING on the SETTLED screen. The wall shows live sessions |
| 515 | # daemon that is not answering contributes no bar of its own and not | 515 | # and nothing else — the layout seed may paint a remembered pane as |
| 516 | # one dead tile per session it used to have. Daemon 1 is up throughout | 516 | # [waiting] for the poll round it takes every opening host to answer, |
| 517 | # and its tiles are what the leg waits on, so "the poll has not landed | 517 | # and the settle collapses it, so the claim is judged on the final |
| 518 | # yet" and "daemon 2 has no bar" cannot be confused. | 518 | # grid (render), not on every transient frame the capture holds. |
| 519 | # Daemon 1 is up throughout and its tiles are what the leg waits on, | ||
| 520 | # so "the poll has not landed yet" and "daemon 2 has no bar" cannot | ||
| 521 | # be confused. | ||
| 519 | # * back: its own default session and nothing else. `c`, which was live | 522 | # * back: its own default session and nothing else. `c`, which was live |
| 520 | # when the daemon died, is not there — asked of `mux d stats` on the | 523 | # when the daemon died, is not there — asked of `mux d stats` on the |
| 521 | # real daemon, not of the wall that would be reporting on its own | 524 | # real daemon, not of the wall that would be reporting on its own |
| @@ -534,12 +537,15 @@ RC=$? | |||
| 534 | set -e | 537 | set -e |
| 535 | [ "$RC" -eq 0 ] || { | 538 | [ "$RC" -eq 0 ] || { |
| 536 | echo "e2e FAIL: hosts restart: the down-host wall exited $RC:"; cat "$OUT.hdpc"; exit 1; } | 539 | echo "e2e FAIL: hosts restart: the down-host wall exited $RC:"; cat "$OUT.hdpc"; exit 1; } |
| 537 | # The down daemon's spelling, ANYWHERE on the screen: a placeholder bar | 540 | # The down daemon's spelling, anywhere on the SETTLED screen: a lingering |
| 538 | # would carry `--sock $SOCKH2` and a dead tile `--sock $SOCKH2#c`, so one | 541 | # bar would carry `--sock $SOCKH2` and a dead tile `--sock $SOCKH2#c`, so |
| 539 | # count of the host's own path catches both. The `expect` above is the | 542 | # one count of the host's own path catches both. The `expect` above is |
| 540 | # control: it waited on daemon 1's bar, so a capture this grep finds | 543 | # the control: it waited on daemon 1's bar, so a grid this grep finds |
| 541 | # nothing in is a PAINTED screen and not a blank one. | 544 | # nothing in is a PAINTED screen and not a blank one. The settle above |
| 542 | HDN=$(grep -c -- "--sock $SOCKH2" "$OUT.hdcap" || true) | 545 | # (past every opening host's first answer) is what makes the final grid |
| 546 | # the settled one. | ||
| 547 | "$RENDER" --cols 80 --rows 44 < "$OUT.hdcap" > "$OUT.hdcap.final" | ||
| 548 | HDN=$(grep -c -- "--sock $SOCKH2" "$OUT.hdcap.final" || true) | ||
| 543 | [ "$HDN" -eq 0 ] || { | 549 | [ "$HDN" -eq 0 ] || { |
| 544 | echo "e2e FAIL: hosts restart: a down daemon named itself on the wall," | 550 | echo "e2e FAIL: hosts restart: a down daemon named itself on the wall," |
| 545 | echo " which shows live sessions and nothing else:" | 551 | echo " which shows live sessions and nothing else:" |
| @@ -565,7 +571,8 @@ set -e | |||
| 565 | grep -q -- "--sock $SOCKH2#0 \[up\]" "$OUT.hrcap" || { | 571 | grep -q -- "--sock $SOCKH2#0 \[up\]" "$OUT.hrcap" || { |
| 566 | echo "e2e FAIL: hosts restart: the reborn daemon's own session never became a tile:" | 572 | echo "e2e FAIL: hosts restart: the reborn daemon's own session never became a tile:" |
| 567 | cat "$OUT.hrpc"; exit 1; } | 573 | cat "$OUT.hrpc"; exit 1; } |
| 568 | if grep -q -- "--sock $SOCKH2#c" "$OUT.hrcap"; then | 574 | "$RENDER" --cols 80 --rows 44 < "$OUT.hrcap" > "$OUT.hrcap.final" |
| 575 | if grep -q -- "--sock $SOCKH2#c" "$OUT.hrcap.final"; then | ||
| 569 | echo "e2e FAIL: hosts restart: the wall resurrected the session the dead daemon held:" | 576 | echo "e2e FAIL: hosts restart: the wall resurrected the session the dead daemon held:" |
| 570 | cat "$OUT.hrpc"; exit 1 | 577 | cat "$OUT.hrpc"; exit 1 |
| 571 | fi | 578 | fi |