a73x

49eacdaf

fix: a picker birth onto a name the wall already has is refused to that pane

a73x   2026-09-03 05:20

Commit message
fix: a picker birth onto a name the wall already has is refused to that pane

nextFreeName reads the daemon's list alone. A daemon that restarted
answers an empty list while the panes it used to serve stand there
wearing gone, so the birth key took '0' and put a second box#0 beside the
first - two panes spelling one leaf, which seedLayout refuses as a repeat
and which therefore costs the whole file at the next start. The pane that
already spells the name is the answer, and Enter there re-creates it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

src/tui/wall_picker.zig
Old New
@@ -580,6 +580,24 @@ pub fn pickBirth(w: Wall, sel: usize) ?usize {
580 // `c` chord would have landed on, reached without a pump to ask. 580 // `c` chord would have landed on, reached without a pump to ask.
581 var name_buf: [proto.session_name_max]u8 = undefined; 581 var name_buf: [proto.session_name_max]u8 = undefined;
582 const name = client.nextFreeName(&name_buf, list); 582 const name = client.nextFreeName(&name_buf, list);
583 // The name is free on the DAEMON, which is not the same as free on this
584 // WALL. A daemon that restarted answers an empty list while the panes
585 // it used to serve stand there wearing `gone`, so the next free name is
586 // `0` and this would put a second `box#0` beside the first — two panes
587 // spelling one leaf, which `seedLayout` refuses as a repeat and which
588 // therefore costs the user the whole file at the next start. The pane
589 // that already spells it is the answer, and Enter there is the key that
590 // re-creates the session in its own rect.
591 if (paneOf(w, sel, name)) |at| {
592 wv.setFocus(w.liveTiles(), w.shared, at);
593 var buf: [96]u8 = undefined;
594 wv.setNotice(w.shared, std.fmt.bufPrint(
595 &buf,
596 "[{s} is already a pane - Enter there re-creates it]",
597 .{name},
598 ) catch "[that session is already a pane]");
599 return at;
600 }
583 const anchor = wall_layout.anchorTile(w.livePresent(), w.shared.sel); 601 const anchor = wall_layout.anchorTile(w.livePresent(), w.shared.sel);
584 const has_anchor = wv.presentCount(w.livePresent()) > 0; 602 const has_anchor = wv.presentCount(w.livePresent()) > 0;
585 // `-A` is inherited only within one host. A chord inherits it because 603 // `-A` is inherited only within one host. A chord inherits it because
src/tui/wall_test_picker.zig
Old New
@@ -908,6 +908,51 @@ test "pickAdd: a listed session becomes a pane once; a second add zooms to it" {
908 try std.testing.expect(std.mem.indexOf(u8, wv.takeNotice(&shared, &buf), "no session") != null); 908 try std.testing.expect(std.mem.indexOf(u8, wv.takeNotice(&shared, &buf), "no session") != null);
909 } 909 }
910 910
911 test "pickBirth: the name a restarted daemon calls free is already a pane, and the birth is refused to it" {
912 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
913 defer arena.deinit();
914 const alloc = arena.allocator();
915 var shared: Shared = undefined;
916 fixture.stoppedWall(alloc, &shared);
917 shared.size = .{ .cols = 120, .rows = 40 };
918 // TWO hosts and the pane on the SECOND: a one-host fixture cannot see a
919 // birth that matched a pane on the wrong machine, and two daemons may
920 // each call their own next name `0`.
921 var hosts_table = [_]Host{
922 fixture.testHost(&shared, "--sock /a", "/a"),
923 fixture.testHost(&shared, "--sock /b", "/b"),
924 };
925 fixture.setList(&hosts_table[0], "0\n");
926 fixture.setList(&hosts_table[1], "0\n");
927 var tiles: [wv.max_tiles]Tile = undefined;
928 var present = [_]bool{false} ** wv.max_tiles;
929 var live: usize = 0;
930 defer fixture.endPumps(tiles[0..live]);
931 const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &hosts_table);
932
933 const pane = wall_picker.pickAdd(w, 1, 0).?;
934 try std.testing.expectEqualStrings("0", tiles[pane].r.session);
935
936 // The daemon restarts: it answers an empty list, the pane it used to
937 // serve stands there wearing `gone`, and `nextFreeName` off that list
938 // hands back `0` — the name the pane already spells.
939 fixture.setList(&hosts_table[1], "");
940 var buf: [96]u8 = undefined;
941 _ = wv.takeNotice(&shared, &buf);
942 shared.sel = 0;
943
944 const at = wall_picker.pickBirth(w, 1);
945 // The pane the user meant, not a second tile onto one session: two
946 // panes spelling one leaf is a repeat, and `seedLayout` refuses a file
947 // with one WHOLE — the wall would be gone at the next start.
948 try std.testing.expectEqual(@as(?usize, pane), at);
949 try std.testing.expectEqual(@as(usize, 1), wv.presentCount(w.livePresent()));
950 try std.testing.expectEqual(pane, shared.sel);
951 const said = wv.takeNotice(&shared, &buf);
952 try std.testing.expect(std.mem.indexOf(u8, said, "already a pane") != null);
953 try std.testing.expect(std.mem.indexOf(u8, said, "0") != null);
954 }
955
911 test "paintPicker: the session level names the host and lists its sessions" { 956 test "paintPicker: the session level names the host and lists its sessions" {
912 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true }); 957 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
913 defer std.posix.close(pipe[0]); 958 defer std.posix.close(pipe[0]);