a73x

9c199243

feat: Enter revives a gone pane in place, and x still drops it

a73x   2026-09-01 10:18

Commit message
feat: Enter revives a gone pane in place, and x still drops it

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

src/tui/wall_test_host.zig
Old New
@@ -965,3 +965,68 @@ test "setFocus: landing on a gone pane says what the two keys are" {
965 wv.setFocus(tiles[0..live], &shared, 1); 965 wv.setFocus(tiles[0..live], &shared, 1);
966 try std.testing.expectEqualStrings(wv.gone_notice, shared.notice[0..shared.notice_len]); 966 try std.testing.expectEqualStrings(wv.gone_notice, shared.notice[0..shared.notice_len]);
967 } 967 }
968
969 test "armRevive: Enter turns a gone pane into a creating attach in the same rect" {
970 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
971 defer arena.deinit();
972 const alloc = arena.allocator();
973 var shared: Shared = undefined;
974 fixture.stoppedWall(alloc, &shared);
975 var tiles: [2]Tile = undefined;
976 try wv.seedTile(&tiles[0], .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box#a", .session = "a" }, .{ .top = 1, .left = 41, .rows = 23, .cols = 39 }, &shared, 0, 0);
977 tiles[0].state = .gone;
978
979 wv.armRevive(&tiles[0]);
980
981 // The tile is no longer a placeholder: it CREATES (the daemon has no
982 // such session - that is what gone means), and it does so exactly
983 // where it stood.
984 try std.testing.expect(!tiles[0].pending);
985 try std.testing.expect(tiles[0].creates);
986 try std.testing.expectEqual(wv.State.connecting, tiles[0].state);
987 try std.testing.expect(tiles[0].alive.load(.acquire));
988 try std.testing.expect(!tiles[0].pump_done.load(.acquire));
989 try std.testing.expectEqual(@as(u16, 41), tiles[0].rect.left);
990 try std.testing.expectEqualStrings("a", tiles[0].r.session);
991 }
992
993 test "keysToFocused: a gone pane eats every byte except Enter" {
994 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
995 defer arena.deinit();
996 const alloc = arena.allocator();
997 var shared: Shared = undefined;
998 fixture.stoppedWall(alloc, &shared);
999 var tiles: [2]Tile = undefined;
1000 // The pump the Enter below spawns holds `*Tile` into this frame, and
1001 // `shared` is this frame too: the test may not return until that thread
1002 // has stored `alive = false`, or the next test's stack is what it reads.
1003 defer fixture.endPumps(tiles[0..1]);
1004 try wv.seedTile(&tiles[0], .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box#a", .session = "a" }, .{ .top = 1, .left = 0, .rows = 23, .cols = 40 }, &shared, 0, 0);
1005 tiles[0].state = .gone;
1006
1007 // Ordinary typing reaches no session and queues nowhere.
1008 wv.keysToFocused(&tiles[0], "ls -la");
1009 try std.testing.expectEqual(@as(usize, 0), tiles[0].in_len);
1010 try std.testing.expect(tiles[0].pending);
1011
1012 // Enter revives. (`running` is false in this fixture, so the spawned
1013 // pump exits at its gate; the transitions are armRevive's, pinned above.)
1014 wv.keysToFocused(&tiles[0], "\r");
1015 try std.testing.expect(!tiles[0].pending);
1016 tiles[0].pump_done.store(true, .release);
1017 }
1018
1019 test "endKey: x on a gone pane drops the tile locally" {
1020 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
1021 defer arena.deinit();
1022 const alloc = arena.allocator();
1023 var shared: Shared = undefined;
1024 fixture.stoppedWall(alloc, &shared);
1025 var tiles: [2]Tile = undefined;
1026 try wv.seedTile(&tiles[0], .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box#a", .session = "a" }, .{ .top = 1, .left = 0, .rows = 23, .cols = 40 }, &shared, 0, 0);
1027 tiles[0].state = .gone;
1028 // A PIN, green from the day it was written: `.drop` already covers
1029 // `!alive`, and a gone pane has no pump. The spec now depends on that
1030 // coincidence, so it is asserted rather than assumed.
1031 try std.testing.expectEqual(wv.EndKey.drop, wv.endKey(&tiles[0], 0));
1032 }
src/tui/wallview.zig
Old New
@@ -974,6 +974,45 @@ pub fn bindTile(w: Wall, i: usize) void {
974 spawnPump(t); 974 spawnPump(t);
975 } 975 }
976 976
977 /// The state half of a revive, split from the thread so a test can pin the
978 /// transitions without racing a pump. `reviveTile` is the only production
979 /// caller.
980 pub fn armRevive(t: *Tile) void {
981 t.shared.paint_mu.lock();
982 t.pending = false;
983 t.creates = true;
984 t.state = .connecting;
985 t.shared.paint_mu.unlock();
986 t.end_seen = false;
987 // The user is looking at this pane — Enter was pressed IN it — so the
988 // claim is armed the way bindTile arms the saved focus.
989 if (t.idx == t.shared.sel) t.claim_pending.store(true, .release);
990 t.pump_done.store(false, .release);
991 t.alive.store(true, .release);
992 }
993
994 /// A gone pane, revived on the user's Enter: the same tile re-arms as a
995 /// CREATING attach — same host, same session name, same rect — so the
996 /// daemon makes the session anew where the old one stood. No tree edit and
997 /// no flatten: reviving re-cuts nothing, which is the promise gone panes
998 /// exist to keep.
999 pub fn reviveTile(t: *Tile) void {
1000 armRevive(t);
1001 spawnPump(t);
1002 }
1003
1004 /// The one door for bytes aimed at the focused tile. A gone pane has no
1005 /// pump, so no byte could reach a session — eaten here rather than queued
1006 /// into `in` where they would greet the NEXT session this tile carries.
1007 /// Enter is the pane's one verb.
1008 pub fn keysToFocused(t: *Tile, keys: []const u8) void {
1009 if (t.pending and t.state == .gone) {
1010 if (std.mem.indexOfAny(u8, keys, "\r\n") != null) reviveTile(t);
1011 return;
1012 }
1013 sendKeys(t, keys);
1014 }
1015
977 /// Every road onto a running wall — chord, fold, prompt — one body. 1016 /// Every road onto a running wall — chord, fold, prompt — one body.
978 pub fn birthTile(w: Wall, b: Birth) ?usize { 1017 pub fn birthTile(w: Wall, b: Birth) ?usize {
979 return birthTileOrRefuse(w, b) catch null; 1018 return birthTileOrRefuse(w, b) catch null;
@@ -1928,7 +1967,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1928 // no tile at all, which is why it is answered ahead of both. 1967 // no tile at all, which is why it is answered ahead of both.
1929 if (input.prefix.picking or wall_picker.isPickAction(cmd.action)) { 1968 if (input.prefix.picking or wall_picker.isPickAction(cmd.action)) {
1930 // Typed AT the session, ahead of the chord, in the same read. 1969 // Typed AT the session, ahead of the chord, in the same read.
1931 if (cmd.forward.len > 0 and z < w.live.* and present[z]) sendKeys(&tiles[z], cmd.forward); 1970 if (cmd.forward.len > 0 and z < w.live.* and present[z]) keysToFocused(&tiles[z], cmd.forward);
1932 var birth_at: ?usize = null; 1971 var birth_at: ?usize = null;
1933 // Opening on a focused tile pre-selects that tile's host: the 1972 // Opening on a focused tile pre-selects that tile's host: the
1934 // machine the user is already on is the one they mean. Judged 1973 // machine the user is already on is the one they mean. Judged
@@ -2050,13 +2089,13 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2050 if (rectHit(tiles[0..live], present[0..live], &shared, ev.row, ev.col)) |hit| { 2089 if (rectHit(tiles[0..live], present[0..live], &shared, ev.row, ev.col)) |hit| {
2051 if (hit != shared.sel) { 2090 if (hit != shared.sel) {
2052 if (ev.at > seg_start) 2091 if (ev.at > seg_start)
2053 sendKeys(&tiles[shared.sel], cmd.forward[seg_start..ev.at]); 2092 keysToFocused(&tiles[shared.sel], cmd.forward[seg_start..ev.at]);
2054 seg_start = ev.at; 2093 seg_start = ev.at;
2055 setFocus(tiles[0..live], &shared, hit); 2094 setFocus(tiles[0..live], &shared, hit);
2056 } 2095 }
2057 } 2096 }
2058 } 2097 }
2059 sendKeys(&tiles[shared.sel], cmd.forward[seg_start..]); 2098 keysToFocused(&tiles[shared.sel], cmd.forward[seg_start..]);
2060 } 2099 }
2061 switch (cmd.action) { 2100 switch (cmd.action) {
2062 .none => {}, 2101 .none => {},