a73x

831be5c9

feat: the picker lists a host's sessions; Enter adds one, c births, x ends with the daemon's two-step

a73x   2026-09-03 05:20

Commit message
feat: the picker lists a host's sessions; Enter adds one, c births, x ends with the daemon's two-step

The popup gains a second level. Enter on a host row opens that daemon's
own session list, with "on this wall" beside the ones the layout already
has and the holder count beside the ones a daemon new enough to say it
reports; Enter on a session row adds it as a pane, joined and never
created; `c` still births; `x` ends through `client.endSession`, a side
connection of its own because the session may have no pane here to ask
through. Esc backs out one level rather than closing the box.

Two carried items ride along. A pump now writes its `.detach` on the way
out of its loop, so a pane removed by `Ctrl-\ x` gives the daemon its
slot back with a goodbye rather than a closed fd. And the `# holds` doc
comment says what the count is: every holder, the asker included, a
number to read and never the verdict the daemon gives an `end_req`.

src/client/client.zig
Old New
@@ -1207,6 +1207,50 @@ pub fn birthSession(
1207 tr.writeFrame(.detach, "") catch {}; 1207 tr.writeFrame(.detach, "") catch {};
1208 } 1208 }
1209 1209
1210 /// What the daemon said about an `end_req`. The reason is COPIED rather
1211 /// than borrowed: the frame it arrived in is freed before this returns, and
1212 /// the picker builds its notice from the reason after the call.
1213 pub const EndOutcome = struct {
1214 accepted: bool,
1215 others: u8,
1216 reason_buf: [proto.end_reply_max_len]u8 = undefined,
1217 reason_len: usize = 0,
1218 pub fn reason(self: *const EndOutcome) []const u8 {
1219 return self.reason_buf[0..self.reason_len];
1220 }
1221 };
1222
1223 /// `end_req` on a side connection of its own: the picker ends a session
1224 /// that may have no pane on this wall, so there is no pump to ask
1225 /// through. The daemon owns the two-step; this only carries `force`.
1226 pub fn endSession(alloc: std.mem.Allocator, target: Target, name: []const u8, force: bool) !EndOutcome {
1227 var tr = try Transport.open(alloc, target, null, -1, null);
1228 defer tr.close();
1229 var buf: [proto.end_req_max_len]u8 = undefined;
1230 const req = proto.encodeEndReq(&buf, force, proto.wireName(name));
1231 const deadline = std.time.milliTimestamp() + birth_budget_ms;
1232 // `exit_status` is asked for as well because it ends the wait: a daemon
1233 // that kills the session before it answers has already said everything
1234 // it is going to, and waiting out the budget for a reply that is not
1235 // coming would park the popup for three seconds.
1236 const f = roundTrip(&tr, alloc, .end_req, req, &.{ .end_reply, .exit_status }, deadline) catch |e| return switch (e) {
1237 error.Timeout => error.Timeout,
1238 else => error.Transport,
1239 };
1240 defer f.deinit(alloc);
1241 // An `exit_status` (or a daemon too old to have an `end_req` arm at all)
1242 // is not a verdict this can report a count from.
1243 if (f.type != .end_reply) return error.Refused;
1244 const r = proto.parseEndReply(f.payload) orelse return error.Refused;
1245 var out: EndOutcome = .{ .accepted = r.accepted, .others = r.others };
1246 // The tail is a PEER's bytes and the buffer is this frame's size, not
1247 // the peer's: a reason longer than any word the daemon owns is cut.
1248 const n = @min(r.reason.len, out.reason_buf.len);
1249 @memcpy(out.reason_buf[0..n], r.reason[0..n]);
1250 out.reason_len = n;
1251 return out;
1252 }
1253
1210 /// Every way of not reaching a daemon, except running out of memory. 1254 /// Every way of not reaching a daemon, except running out of memory.
1211 fn oomOrTransport(e: anyerror) error{ OutOfMemory, Transport } { 1255 fn oomOrTransport(e: anyerror) error{ OutOfMemory, Transport } {
1212 // `mux hosts` prints `[unreachable]` for a Transport, so an allocation 1256 // `mux hosts` prints `[unreachable]` for a Transport, so an allocation
@@ -2945,6 +2989,103 @@ test "birthSession: an exit_status before any snapshot is Refused, and nothing i
2945 try std.testing.expectEqual(@as(usize, 1), fake.n); 2989 try std.testing.expectEqual(@as(usize, 1), fake.n);
2946 } 2990 }
2947 2991
2992 /// A daemon stand-in for the end test. One connection per `endSession`, the
2993 /// same shape `BirthFake` stands in for a birth with — and for the same
2994 /// reason: the `client` module has no import edge to `daemon`, so a REAL
2995 /// daemon cannot be stood up in this test binary. The verdicts are scripted
2996 /// here because they are the DAEMON's to make; that half is pinned against a
2997 /// real daemon in `server_test_session.zig` ("end_req with another client
2998 /// attached is refused with the count"). What this file owns, and what this
2999 /// fake therefore records, is the request `endSession` puts on the wire and
3000 /// the outcome it makes of the reply.
3001 const EndFake = struct {
3002 listener: std.net.Server,
3003 /// One scripted answer per connection, in order.
3004 replies: []const proto.EndReply,
3005 /// What each request carried, so the client's half is assertable.
3006 force: [4]bool = @splat(false),
3007 names: [4][proto.session_name_max]u8 = undefined,
3008 name_lens: [4]usize = @splat(0),
3009 n: usize = 0,
3010
3011 fn serve(self: *EndFake) void {
3012 const alloc = std.testing.allocator;
3013 while (self.n < self.replies.len) {
3014 const conn = self.listener.accept() catch return;
3015 defer conn.stream.close();
3016 const f = (proto.readFrame(alloc, conn.stream.handle) catch return) orelse return;
3017 defer f.deinit(alloc);
3018 if (f.type != .end_req or f.payload.len < proto.end_req_len) return;
3019 const at = self.n;
3020 self.force[at] = f.payload[0] != 0;
3021 const name = f.payload[proto.end_req_len..];
3022 @memcpy(self.names[at][0..name.len], name);
3023 self.name_lens[at] = name.len;
3024 self.n += 1;
3025 const r = self.replies[at];
3026 // The daemon's own encoder, so the bytes this test decodes are
3027 // the bytes a daemon writes rather than a second spelling.
3028 var buf: [proto.end_reply_max_len]u8 = undefined;
3029 proto.writeFrame(
3030 conn.stream.handle,
3031 .end_reply,
3032 proto.encodeEndReply(&buf, r.accepted, r.others, r.reason),
3033 ) catch return;
3034 }
3035 }
3036
3037 fn nameOf(self: *const EndFake, i: usize) []const u8 {
3038 return proto.resolveName(self.names[i][0..self.name_lens[i]]);
3039 }
3040 };
3041
3042 test "endSession: a held session refuses with the count, force ends it, and an unknown name is refused with the daemon's reason" {
3043 const alloc = std.testing.allocator;
3044 var tmp = try TmpDir.make();
3045 defer tmp.cleanup();
3046 const sp = try std.fmt.allocPrint(alloc, "{s}/end.sock", .{tmp.path()});
3047 defer alloc.free(sp);
3048 const addr = try std.net.Address.initUnix(sp);
3049 var fake = EndFake{
3050 .listener = try addr.listen(.{}),
3051 .replies = &.{
3052 .{ .accepted = false, .others = 1, .reason = proto.end_reason.others_attached },
3053 .{ .accepted = true, .others = 0, .reason = proto.end_reason.accepted },
3054 .{ .accepted = false, .others = 0, .reason = proto.end_reason.no_session },
3055 },
3056 };
3057 defer fake.listener.deinit();
3058 const th = try std.Thread.spawn(.{}, EndFake.serve, .{&fake});
3059
3060 const first = try endSession(alloc, .{ .sock = sp }, "0", false);
3061 try std.testing.expect(!first.accepted);
3062 try std.testing.expectEqual(@as(u8, 1), first.others);
3063 // The picker's notice is built from this, so a refusal that lost its
3064 // reason would read as an end that happened.
3065 try std.testing.expectEqualStrings(proto.end_reason.others_attached, first.reason());
3066
3067 const forced = try endSession(alloc, .{ .sock = sp }, "0", true);
3068 try std.testing.expect(forced.accepted);
3069 try std.testing.expectEqualStrings("", forced.reason());
3070
3071 const missing = try endSession(alloc, .{ .sock = sp }, "nope", false);
3072 try std.testing.expect(!missing.accepted);
3073 try std.testing.expectEqualStrings(proto.end_reason.no_session, missing.reason());
3074 th.join();
3075
3076 // Three connections, one question each: the picker ends a session with
3077 // no pane on this wall, so there is no pump's link to ride.
3078 try std.testing.expectEqual(@as(usize, 3), fake.n);
3079 // `force` is the SECOND press and nothing else — the client never
3080 // decides to force, it only remembers being told to ask twice.
3081 try std.testing.expectEqual([3]bool{ false, true, false }, fake.force[0..3].*);
3082 // The default session rides the wire as an empty tail; `endSession` must
3083 // spell it that way or a daemon reads a session called "0" it has not got.
3084 try std.testing.expectEqual(@as(usize, 0), fake.name_lens[0]);
3085 try std.testing.expectEqualStrings("0", fake.nameOf(0));
3086 try std.testing.expectEqualStrings("nope", fake.nameOf(2));
3087 }
3088
2948 /// A daemon stand-in for the list test: accepts once and answers the first 3089 /// A daemon stand-in for the list test: accepts once and answers the first
2949 /// `sessions_req` with a fixed reply. 3090 /// `sessions_req` with a fixed reply.
2950 const ListFake = struct { 3091 const ListFake = struct {
src/engine/protocol.zig
Old New
@@ -928,8 +928,12 @@ pub const sessions_meta_version_max = 32;
928 pub const sessions_meta_max = 928 pub const sessions_meta_max =
929 1 + sessions_meta_prefix.len + sessions_meta_version_max + sessions_meta_stale_word.len; 929 1 + sessions_meta_prefix.len + sessions_meta_version_max + sessions_meta_stale_word.len;
930 /// One `# holds NAME N` line per session, appended by a daemon that can 930 /// One `# holds NAME N` line per session, appended by a daemon that can
931 /// count: how many clients hold that session. The picker's session list 931 /// count: how many clients hold that session. The count is EVERY holder,
932 /// shows it, and the end key's first press is judged against it. Spelled 932 /// the asker included, so it is a number to READ and never a verdict: the
933 /// picker's session row shows it as the total, and whether an `end_req`
934 /// goes through is judged by the daemon against the OTHERS — the holders
935 /// that are not the connection asking. A client that decided from this
936 /// number would refuse to end a session only it is in. Spelled
933 /// as a `#` line so `sessionsIter`, which yields only valid session names, 937 /// as a `#` line so `sessionsIter`, which yields only valid session names,
934 /// skips it on a client that predates it — exactly as it skips the meta 938 /// skips it on a client that predates it — exactly as it skips the meta
935 /// line — and a daemon that predates it sends none, which 939 /// line — and a daemon that predates it sends none, which
src/tui/interact.zig
Old New
@@ -66,6 +66,10 @@ pub const Highlight = struct {
66 pub const PrefixFilter = struct { 66 pub const PrefixFilter = struct {
67 pub const Dir = enum { left, down, up, right }; 67 pub const Dir = enum { left, down, up, right };
68 68
69 /// The picker's two lists: the hosts file's daemons, and one daemon's
70 /// own sessions.
71 pub const PickLevel = enum { hosts, sessions };
72
69 /// Callers switch on it, so a new variant is additive. 73 /// Callers switch on it, so a new variant is additive.
70 pub const Action = union(enum) { 74 pub const Action = union(enum) {
71 none, 75 none,
@@ -100,6 +104,16 @@ pub const PrefixFilter = struct {
100 pick_forget, 104 pick_forget,
101 pick_add_open, 105 pick_add_open,
102 pick_close, 106 pick_close,
107 /// Enter. At the host level it opens that host's session list; at
108 /// the session level it chooses a session and closes the popup.
109 /// One action, because the filter has already flipped `pick_level`
110 /// by the time the caller reads it — the NEW level says which of
111 /// the two just happened.
112 pick_enter,
113 /// Esc at the session level: back to the hosts, popup still up.
114 pick_back,
115 /// `x` at the session level: end that session on its daemon.
116 pick_end,
103 /// ssh asked something and the user answered. Borrows the filter's 117 /// ssh asked something and the user answered. Borrows the filter's
104 /// buffer until the next feed, as `add_tile` does. An EMPTY answer 118 /// buffer until the next feed, as `add_tile` does. An EMPTY answer
105 /// is an answer: a key whose passphrase is empty is a real key. 119 /// is an answer: a key whose passphrase is empty is a real key.
@@ -146,6 +160,10 @@ pub const PrefixFilter = struct {
146 /// until it closes — so a `j` aimed at the rows can never reach a 160 /// until it closes — so a `j` aimed at the rows can never reach a
147 /// shell. The driver paints; this owns which key means what. 161 /// shell. The driver paints; this owns which key means what.
148 picking: bool = false, 162 picking: bool = false,
163 /// Which list the popup shows. The filter owns it because Enter and Esc
164 /// mean different things at each level, and a key must resolve to ONE
165 /// action without the wall's help.
166 pick_level: PickLevel = .hosts,
149 167
150 /// ssh is waiting on an answer. Over EVERYTHING, `picking` included: a 168 /// ssh is waiting on an answer. Over EVERYTHING, `picking` included: a
151 /// prompt is not a mode the user chose, it arrived. Every byte is the 169 /// prompt is not a mode the user chose, it arrived. Every byte is the
@@ -280,19 +298,48 @@ pub const PrefixFilter = struct {
280 // leaves too — its digits read as row selections. 298 // leaves too — its digits read as row selections.
281 if (tail.len > 0 and (tail[0] == '[' or tail[0] == 'O')) 299 if (tail.len > 0 and (tail[0] == '[' or tail[0] == 'O'))
282 return .{ .forward = buf[0..kept], .action = .none }; 300 return .{ .forward = buf[0..kept], .action = .none };
301 // One level at a time: the session list was reached
302 // through the host list, and an Esc that closed the
303 // whole popup would cost a reopen to change machine.
304 if (self.pick_level == .sessions) {
305 self.pick_level = .hosts;
306 return .{ .forward = buf[0..kept], .action = .pick_back };
307 }
283 self.picking = false; 308 self.picking = false;
284 return .{ .forward = buf[0..kept], .action = .pick_close }; 309 return .{ .forward = buf[0..kept], .action = .pick_close };
285 }, 310 },
286 'j' => return .{ .forward = buf[0..kept], .action = .{ .pick_move = 1 } }, 311 'j' => return .{ .forward = buf[0..kept], .action = .{ .pick_move = 1 } },
287 'k' => return .{ .forward = buf[0..kept], .action = .{ .pick_move = -1 } }, 312 'k' => return .{ .forward = buf[0..kept], .action = .{ .pick_move = -1 } },
288 '1'...'9' => return .{ .forward = buf[0..kept], .action = .{ .pick_select = @intCast(b - '0') } }, 313 '1'...'9' => return .{ .forward = buf[0..kept], .action = .{ .pick_select = @intCast(b - '0') } },
289 '\r', '\n', 'c' => { 314 '\r', '\n' => {
315 // Enter descends, then chooses. The level is flipped
316 // BEFORE the caller sees the action, so `.sessions`
317 // means "the list just opened" and `.hosts` means "a
318 // session was picked and the popup is closing".
319 if (self.pick_level == .hosts) {
320 self.pick_level = .sessions;
321 return .{ .forward = buf[0..kept], .action = .pick_enter };
322 }
290 self.picking = false; 323 self.picking = false;
324 self.pick_level = .hosts;
325 return .{ .forward = buf[0..kept], .action = .pick_enter };
326 },
327 // A new session on the selected host, from either level:
328 // the host is what a birth needs, and the session list
329 // is exactly where a user decides none of them will do.
330 'c' => {
331 self.picking = false;
332 self.pick_level = .hosts;
291 return .{ .forward = buf[0..kept], .action = .pick_birth }; 333 return .{ .forward = buf[0..kept], .action = .pick_birth };
292 }, 334 },
293 // Closed, like a birth: a forget takes the host's tiles
294 // off the wall, and the user is owed the wall that made.
295 'x' => { 335 'x' => {
336 // An end leaves the popup up: the daemon's first
337 // answer is a count to read, and the second press
338 // that forces has to land on the same row.
339 if (self.pick_level == .sessions)
340 return .{ .forward = buf[0..kept], .action = .pick_end };
341 // Closed, like a birth: a forget takes the host's tiles
342 // off the wall, and the user is owed the wall that made.
296 self.picking = false; 343 self.picking = false;
297 return .{ .forward = buf[0..kept], .action = .pick_forget }; 344 return .{ .forward = buf[0..kept], .action = .pick_forget };
298 }, 345 },
@@ -304,6 +351,7 @@ pub const PrefixFilter = struct {
304 }, 351 },
305 's', 0x03 => { 352 's', 0x03 => {
306 self.picking = false; 353 self.picking = false;
354 self.pick_level = .hosts;
307 return .{ .forward = buf[0..kept], .action = .pick_close }; 355 return .{ .forward = buf[0..kept], .action = .pick_close };
308 }, 356 },
309 else => {}, 357 else => {},
@@ -2272,18 +2320,77 @@ test "interact: a digit selects a picker row and leaves the picker open" {
2272 try std.testing.expect(f.picking); 2320 try std.testing.expect(f.picking);
2273 } 2321 }
2274 2322
2275 test "interact: Enter and c both birth on the selected host, and close the picker" { 2323 test "interact: c births on the selected host and closes the picker; Enter no longer does" {
2276 for ([_][]const u8{ "\r", "\n", "c" }) |key| { 2324 var f: PrefixFilter = .{};
2277 var f: PrefixFilter = .{}; 2325 var open = "\x1cs".*;
2278 var open = "\x1cs".*; 2326 _ = f.feed(&open);
2279 _ = f.feed(&open); 2327 var key = "c".*;
2328 try std.testing.expectEqual(PrefixFilter.Action.pick_birth, f.feed(&key).action);
2329 try std.testing.expect(!f.picking);
2330 // Enter is the descend key now: it opens the selected host's sessions
2331 // and births nothing, so a birth is `c` and only `c`.
2332 for ([_][]const u8{ "\r", "\n" }) |ent| {
2333 var g: PrefixFilter = .{};
2334 _ = g.feed(&open);
2280 var keys: [2]u8 = undefined; 2335 var keys: [2]u8 = undefined;
2281 @memcpy(keys[0..key.len], key); 2336 @memcpy(keys[0..ent.len], ent);
2282 try std.testing.expectEqual(PrefixFilter.Action.pick_birth, f.feed(keys[0..key.len]).action); 2337 try std.testing.expectEqual(PrefixFilter.Action.pick_enter, g.feed(keys[0..ent.len]).action);
2283 try std.testing.expect(!f.picking); 2338 try std.testing.expect(g.picking);
2284 } 2339 }
2285 } 2340 }
2286 2341
2342 test "interact: picker levels: Enter on a host opens its sessions, Esc backs out a level, Enter on a session closes with pick_enter" {
2343 var f = PrefixFilter{};
2344 var open = [_]u8{ detach_key, 's' };
2345 try std.testing.expectEqual(PrefixFilter.Action.pick_open, f.feed(&open).action);
2346 try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level);
2347
2348 var enter = [_]u8{'\r'};
2349 try std.testing.expectEqual(PrefixFilter.Action.pick_enter, f.feed(&enter).action);
2350 try std.testing.expect(f.picking);
2351 try std.testing.expectEqual(PrefixFilter.PickLevel.sessions, f.pick_level);
2352
2353 // Esc at the session level backs out one level rather than closing:
2354 // the list of machines is where the user came from.
2355 var esc = [_]u8{0x1b};
2356 try std.testing.expectEqual(PrefixFilter.Action.pick_back, f.feed(&esc).action);
2357 try std.testing.expect(f.picking);
2358 try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level);
2359
2360 _ = f.feed(&enter);
2361 try std.testing.expectEqual(PrefixFilter.Action.pick_enter, f.feed(&enter).action);
2362 try std.testing.expect(!f.picking);
2363 // The level goes home with the close, so the next open is a host list.
2364 try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level);
2365
2366 // ...and a second Esc, back at the host level, closes.
2367 _ = f.feed(&open);
2368 try std.testing.expectEqual(PrefixFilter.Action.pick_close, f.feed(&esc).action);
2369 try std.testing.expect(!f.picking);
2370 }
2371
2372 test "interact: picker levels: x forgets at the host level and ends at the session level; c births at either; every byte stays the popup's" {
2373 var f = PrefixFilter{};
2374 var open = [_]u8{ detach_key, 's' };
2375 _ = f.feed(&open);
2376 var x = [_]u8{'x'};
2377 try std.testing.expectEqual(PrefixFilter.Action.pick_forget, f.feed(&x).action);
2378 try std.testing.expect(!f.picking);
2379
2380 _ = f.feed(&open);
2381 var enter = [_]u8{'\r'};
2382 _ = f.feed(&enter);
2383 const ended = f.feed(&x);
2384 try std.testing.expectEqual(PrefixFilter.Action.pick_end, ended.action);
2385 try std.testing.expectEqual(@as(usize, 0), ended.forward.len);
2386 try std.testing.expect(f.picking); // the popup stays up to show the count or the end
2387
2388 var c = [_]u8{'c'};
2389 try std.testing.expectEqual(PrefixFilter.Action.pick_birth, f.feed(&c).action);
2390 try std.testing.expect(!f.picking);
2391 try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level);
2392 }
2393
2287 test "interact: x forgets the selected host and closes the picker" { 2394 test "interact: x forgets the selected host and closes the picker" {
2288 var f: PrefixFilter = .{}; 2395 var f: PrefixFilter = .{};
2289 var open = "\x1cs".*; 2396 var open = "\x1cs".*;
@@ -2362,12 +2469,12 @@ test "interact: the picker eats prose, so a key never reaches a session" {
2362 const out = f.feed(&prose); 2469 const out = f.feed(&prose);
2363 try std.testing.expectEqualStrings("", out.forward); 2470 try std.testing.expectEqualStrings("", out.forward);
2364 try std.testing.expectEqual(PrefixFilter.Action.none, out.action); 2471 try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
2365 // The `\n` behind it is the picker's own birth key, and the letters 2472 // The `\n` behind it is the picker's own descend key, and the letters
2366 // ahead of it went nowhere at all — no shell saw one. 2473 // ahead of it went nowhere at all — no shell saw one.
2367 var enter = "\n".*; 2474 var enter = "\n".*;
2368 const done = f.feed(&enter); 2475 const done = f.feed(&enter);
2369 try std.testing.expectEqualStrings("", done.forward); 2476 try std.testing.expectEqualStrings("", done.forward);
2370 try std.testing.expectEqual(PrefixFilter.Action.pick_birth, done.action); 2477 try std.testing.expectEqual(PrefixFilter.Action.pick_enter, done.action);
2371 } 2478 }
2372 2479
2373 test "interact: the colon chord is gone, the picker's a replaced it" { 2480 test "interact: the colon chord is gone, the picker's a replaced it" {
src/tui/wall_picker.zig
Old New
@@ -1,7 +1,10 @@
1 //! `Ctrl-\ s`: the host picker's popup. A MODE of the wall's prefix filter, so 1 //! `Ctrl-\ s`: the host picker's popup. A MODE of the wall's prefix filter, so
2 //! every byte typed here is the popup's and none reaches a session. Rows are 2 //! every byte typed here is the popup's and none reaches a session. Rows are
3 //! the hosts file's daemons in file order with the poller's last answer beside 3 //! the hosts file's daemons in file order with the poller's last answer beside
4 //! each; Enter births, `x` forgets, `a` is the spelling editor. 4 //! each; Enter opens that host's SESSIONS, `c` births, `x` forgets, `a` is the
5 //! spelling editor. The session level is the second list: Enter adds one as a
6 //! pane, `c` still births, `x` ends it through the daemon's two-step, and Esc
7 //! goes back to the hosts rather than closing the box.
5 const std = @import("std"); 8 const std = @import("std");
6 const proto = @import("term").protocol; 9 const proto = @import("term").protocol;
7 const client = @import("client"); 10 const client = @import("client");
@@ -11,6 +14,7 @@ const interact = @import("interact.zig");
11 const askpass = @import("client").askpass; 14 const askpass = @import("client").askpass;
12 const wall_host = @import("wall_host.zig"); 15 const wall_host = @import("wall_host.zig");
13 const wall_layout = @import("wall_layout.zig"); 16 const wall_layout = @import("wall_layout.zig");
17 const wall_pump = @import("wall_pump.zig");
14 const wv = @import("wallview.zig"); 18 const wv = @import("wallview.zig");
15 const Host = wall_host.Host; 19 const Host = wall_host.Host;
16 const Shared = wv.Shared; 20 const Shared = wv.Shared;
@@ -314,11 +318,251 @@ pub fn isPickAction(a: interact.PrefixFilter.Action) bool {
314 .pick_forget, 318 .pick_forget,
315 .pick_add_open, 319 .pick_add_open,
316 .pick_close, 320 .pick_close,
321 .pick_enter,
322 .pick_back,
323 .pick_end,
317 => true, 324 => true,
318 else => false, 325 else => false,
319 }; 326 };
320 } 327 }
321 328
329 /// How many sessions a host's last answer named — the session level's row
330 /// count, and what a row step and a digit are clamped against. Takes the
331 /// table and an index rather than a host: `picker_sel` can name a slot the
332 /// table does not have (an empty hosts file leaves it at 0), and a level
333 /// that indexed that would fault on a keystroke.
334 pub fn sessionCount(host_table: []Host, host: usize) usize {
335 if (host >= host_table.len) return 0;
336 var list_buf: [proto.sessions_reply_max]u8 = undefined;
337 var it = proto.sessionsIter(host_table[host].poll.snapshot(&list_buf));
338 var n: usize = 0;
339 while (it.next()) |_| n += 1;
340 return n;
341 }
342
343 /// The session row `d` away. WRAPPED, unlike `pickerStep`: a session list is
344 /// short and every row on it is a session that already exists, so rolling
345 /// round costs nothing — the key that acts is Enter, not the move.
346 pub fn rowStep(row: usize, d: i8, n: usize) usize {
347 if (n == 0) return 0;
348 if (d < 0) return if (row == 0) n - 1 else row - 1;
349 return if (row + 1 >= n) 0 else row + 1;
350 }
351
352 /// The tile showing `name` on `host`, or null. Per HOST, because two daemons
353 /// may each have a session called `work` and a lookup by name alone would
354 /// mark the wrong one.
355 fn paneOf(w: Wall, host: usize, name: []const u8) ?usize {
356 for (w.liveTiles(), w.livePresent(), 0..) |*t, p, i| {
357 if (p and wall_host.ownedBy(t, host) and
358 std.mem.eql(u8, proto.resolveName(t.r.session), name)) return i;
359 }
360 return null;
361 }
362
363 /// The name on a session row of a list already in hand, copied out: the
364 /// list is a snapshot on some caller's stack and every caller outlives it.
365 fn nameAt(list: []const u8, row: usize, out: *[proto.session_name_max]u8) ?[]const u8 {
366 var it = proto.sessionsIter(list);
367 var i: usize = 0;
368 while (it.next()) |name| : (i += 1) {
369 if (i != row) continue;
370 // `sessionsIter` yields only valid names, which are bounded by
371 // `session_name_max` — the guard is here so a peer that got past
372 // the iterator cannot smash this buffer.
373 if (name.len > out.len) return null;
374 @memcpy(out[0..name.len], name);
375 return out[0..name.len];
376 }
377 return null;
378 }
379
380 fn sessionAt(h: *Host, row: usize, out: *[proto.session_name_max]u8) ?[]const u8 {
381 var list_buf: [proto.sessions_reply_max]u8 = undefined;
382 return nameAt(h.poll.snapshot(&list_buf), row, out);
383 }
384
385 /// Whether this daemon counts holders at all — whether ANY row it listed
386 /// carries a `# holds` line. The counting and the `end_req` arm arrived in
387 /// the same release, so a daemon that says nothing about holders is one
388 /// that will answer an `end_req` with silence.
389 fn countsHolds(list: []const u8) bool {
390 var it = proto.sessionsIter(list);
391 while (it.next()) |name| {
392 if (proto.parseSessionsHolds(list, name) != null) return true;
393 }
394 return false;
395 }
396
397 /// The second level: one row per session the host's last answer named.
398 /// "on this wall" when the layout already has it, and the holder count when
399 /// the daemon is new enough to say (`proto.parseSessionsHolds`); an old
400 /// daemon's rows carry no count rather than a zero that would read as "safe
401 /// to end". The count is EVERY holder, this client included — what the end
402 /// key gets is the DAEMON's verdict on who else is there, not this number.
403 pub fn sessionRows(body: *PickerBody, w: Wall, host: usize, sel_row: usize, cols: u16) void {
404 body.n = 0;
405 if (host >= w.hosts.len) return;
406 const h = &w.hosts[host];
407 var list_buf: [proto.sessions_reply_max]u8 = undefined;
408 const list = h.poll.snapshot(&list_buf);
409 // Counted before anything is spelled, `pickerRows`' reason: the padding
410 // is the LIST's, and a row cannot know from its own number whether a 10
411 // is coming.
412 var count: usize = 0;
413 var counter = proto.sessionsIter(list);
414 while (counter.next()) |_| count += 1;
415 const wide = count >= 10;
416 var it = proto.sessionsIter(list);
417 var row: usize = 0;
418 while (it.next()) |name| : (row += 1) {
419 if (body.n >= wv.max_tiles) break;
420 var state_buf: [48]u8 = undefined;
421 var state: []const u8 = "";
422 const on_wall = paneOf(w, host, name) != null;
423 if (proto.parseSessionsHolds(list, name)) |n| {
424 state = std.fmt.bufPrint(&state_buf, "{s}{d} client{s}", .{
425 if (on_wall) "on this wall, " else "",
426 n,
427 if (n == 1) "" else "s",
428 }) catch "";
429 } else if (on_wall) state = "on this wall";
430 body.host[body.n] = host;
431 body.lens[body.n] = pickerRow(&body.text[body.n], body.n + 1, wide, name, state, row == sel_row, cols).len;
432 body.n += 1;
433 }
434 }
435
436 /// Enter on a session row: a pane for it, joined (never created), zoomed to.
437 /// A session already on the wall is only zoomed to. The layout is written,
438 /// because this is one of the three places a pane comes from.
439 pub fn pickAdd(w: Wall, host: usize, row: usize) ?usize {
440 // A forgotten host's poller has exited, so a pane born on one could
441 // never be confirmed or vanished — `pickBirth`'s reason, and the same
442 // here because a joined pane is graded by that poller too.
443 if (host >= w.hosts.len or w.hosts[host].forgotten.load(.acquire)) {
444 wv.setNotice(w.shared, "[no host to add a session from - a adds one]");
445 return null;
446 }
447 const h = &w.hosts[host];
448 var name_buf: [proto.session_name_max]u8 = undefined;
449 const name = sessionAt(h, row, &name_buf) orelse {
450 // The list moved under the popup: a poll landed between the paint
451 // and the key, and the row the user chose is not there any more.
452 wv.setNotice(w.shared, "[no session on that row]");
453 return null;
454 };
455 if (paneOf(w, host, name)) |at| {
456 // Already on the wall: the answer is the pane they meant, not a
457 // second tile onto one session.
458 wv.setFocus(w.liveTiles(), w.shared, at);
459 return at;
460 }
461 // Enter IS the ask, `pickBirth`'s reason: the row may be the one the
462 // poller calls unreachable, and choosing it means starting that daemon.
463 var target = h.spec.target;
464 if (target == .hand) target.hand.asked = true;
465 const anchor = wall_layout.anchorTile(w.livePresent(), w.shared.sel);
466 const has_anchor = wv.presentCount(w.livePresent()) > 0;
467 const at = wv.birthTile(w, .{
468 // `name` is this stack's buffer until the wall takes the tile —
469 // see `Birth.borrowed`.
470 // No `-A`: the popup can cross to a host the user never offered an
471 // agent, and joining a session is not the place to hand one over.
472 .r = .{ .target = target, .label = "", .session = name, .agent = false },
473 .from = anchor,
474 .place = .beside_focus,
475 // JOINED: the daemon already has this session, and a create would
476 // ask for a name it has.
477 .creates = false,
478 .born_from = if (has_anchor) anchor else null,
479 // A refusal is one pane's, not the wall's: the popup put this here,
480 // and an empty wall must survive the daemon saying no.
481 .keeps_wall = true,
482 .host = host,
483 .borrowed = true,
484 }) orelse {
485 wv.setNotice(w.shared, "[no room on the wall for another pane]");
486 return null;
487 };
488 wv.spawnPump(&w.tiles[at]);
489 wv.setFocus(w.liveTiles(), w.shared, at);
490 wall_layout.persist(w);
491 return at;
492 }
493
494 /// `x` on a session row: the daemon's two-step, from a side connection of
495 /// its own — the session may have no pane on this wall, so there is no pump
496 /// to ask through. The first press on a session others hold is refused with
497 /// the count and arms 3 s; a second press inside that window forces. Every
498 /// other refusal arms NOTHING: only "others attached" is a question the
499 /// user can answer by pressing again.
500 pub fn pickEnd(w: Wall, host: usize, row: usize, now: i64) void {
501 if (host >= w.hosts.len) return;
502 const h = &w.hosts[host];
503 var list_buf: [proto.sessions_reply_max]u8 = undefined;
504 const list = h.poll.snapshot(&list_buf);
505 var name_buf: [proto.session_name_max]u8 = undefined;
506 const name = nameAt(list, row, &name_buf) orelse {
507 wv.setNotice(w.shared, "[no session on that row]");
508 return;
509 };
510 // Refused HERE and not on the wire: a daemon with no `end_req` arm
511 // answers nothing at all, so the press would spend the whole reply
512 // budget with the wall frozen before saying anything. Same sentence the
513 // pump's own chord uses, because it is the same fact about the box.
514 if (!countsHolds(list)) {
515 wv.setNotice(w.shared, "[daemon too old to end a session]");
516 return;
517 }
518 const armed = w.shared.pick_end.armedFor(host, name, now);
519 // The POLLER's recipe, never the row's own target: an end must not
520 // start a daemon and must not reach for a terminal. `poll_target`
521 // carries ssh's `BatchMode=yes` and a connect timeout and has `asked`
522 // false, so a dark host fails in seconds instead of parking the
523 // keyboard thread in the TCP retry schedule with a password prompt
524 // going to /dev/tty under the popup.
525 const out = client.endSession(w.alloc, h.spec.poll_target, name, armed) catch |e| {
526 var buf: [96]u8 = undefined;
527 wv.setNotice(w.shared, std.fmt.bufPrint(
528 &buf,
529 "[could not ask {s} to end {s}: {s}]",
530 .{ h.spec.spelling, name, @errorName(e) },
531 ) catch "[could not ask the daemon]");
532 return;
533 };
534 var buf: [96]u8 = undefined;
535 if (out.accepted) {
536 // An accepted end DISARMS: the window must not outlive its session,
537 // and the row's number is about to belong to something else.
538 w.shared.pick_end.clear();
539 wv.setNotice(w.shared, std.fmt.bufPrint(
540 &buf,
541 "[ending {s} on {s}]",
542 .{ name, h.spec.spelling },
543 ) catch "[ending the session]");
544 } else if (out.others > 0) {
545 w.shared.pick_end.arm(host, name, now + wv.end_arm_ms);
546 wv.setNotice(w.shared, std.fmt.bufPrint(&buf, "[{s}: {d} other{s} attached - x again to end]", .{
547 name, out.others, if (out.others == 1) "" else "s",
548 }) catch "[others attached - x again to end]");
549 } else {
550 // Not a two-step: the daemon has not got that session, or could not
551 // read the frame. Arming here would leave the next `x` forcing an
552 // end nobody said was blocked. Said in THIS client's words —
553 // `parseEndReply` hands back the peer's bytes unfiltered.
554 w.shared.pick_end.clear();
555 wv.setNotice(w.shared, std.fmt.bufPrint(
556 &buf,
557 "[{s}: {s}]",
558 .{ name, wall_pump.endRefusalWord(out.reason()) },
559 ) catch "[the daemon refused to end that session]");
560 }
561 // The row must leave the list without waiting out a poll: the end is
562 // the daemon's to carry out, and this is what asks it when.
563 h.poll.poke.store(true, .release);
564 }
565
322 /// Enter or `c`: a new session on the SELECTED host, tile or no tile. 566 /// Enter or `c`: a new session on the SELECTED host, tile or no tile.
323 /// Null when nothing was made. 567 /// Null when nothing was made.
324 pub fn pickBirth(w: Wall, sel: usize) ?usize { 568 pub fn pickBirth(w: Wall, sel: usize) ?usize {
@@ -420,10 +664,22 @@ pub fn pickForget(w: Wall, sel: usize, path: ?[]const u8) void {
420 wv.setNotice(w.shared, said); 664 wv.setNotice(w.shared, said);
421 } 665 }
422 666
667 /// Which of the popup's two lists is on screen, and where its selection is.
668 /// The host index is `sel`, which both levels use — the session level lists
669 /// the sessions of the host `sel` names.
670 pub const PickerView = struct {
671 level: interact.PrefixFilter.PickLevel = .hosts,
672 /// The selected SESSION row. Unread at the host level, where `sel` is
673 /// the selection.
674 row: usize = 0,
675 };
676
423 /// The popup, painted by the KEYBOARD thread — the only one that knows the 677 /// The popup, painted by the KEYBOARD thread — the only one that knows the
424 /// picker exists — on open, on every key, and on every list that lands under 678 /// picker exists — on open, on every key, and on every list that lands under
425 /// it, so the state column is live while the user reads it. 679 /// it, so the state column is live while the user reads it.
426 pub fn paintPicker(shared: *Shared, host_table: []Host, sel: usize, line: ?[]const u8) void { 680 pub fn paintPicker(w: Wall, sel: usize, view: PickerView, line: ?[]const u8) void {
681 const shared = w.shared;
682 const host_table = w.hosts;
427 if (!shared.is_tty) return; 683 if (!shared.is_tty) return;
428 // The flag is set before the lock and READ under it (`tilePaintBegin`), 684 // The flag is set before the lock and READ under it (`tilePaintBegin`),
429 // which is what orders the two: a pump either takes `paint_mu` first and 685 // which is what orders the two: a pump either takes `paint_mu` first and
@@ -434,10 +690,26 @@ pub fn paintPicker(shared: *Shared, host_table: []Host, sel: usize, line: ?[]con
434 defer shared.paint_mu.unlock(); 690 defer shared.paint_mu.unlock();
435 const cols = shared.size.cols; 691 const cols = shared.size.cols;
436 const rows = shared.size.rows; 692 const rows = shared.size.rows;
437 const w: u16 = @min(cols, @as(u16, picker_row_max)); 693 const box_w: u16 = @min(cols, @as(u16, picker_row_max));
438 const left: u16 = (cols -| w) / 2; 694 const left: u16 = (cols -| box_w) / 2;
439 var body: PickerBody = .{}; 695 var body: PickerBody = .{};
440 pickerRows(&body, host_table, sel, w); 696 // The selected row differs by level: at the host level it is wherever
697 // the selected HOST landed in the list a forgotten one has left; at the
698 // session level the row IS the selection.
699 var sel_row: usize = 0;
700 var title_buf: [picker_row_max]u8 = undefined;
701 var title: []const u8 = " hosts";
702 if (view.level == .sessions) {
703 sessionRows(&body, w, sel, view.row, box_w);
704 sel_row = @min(view.row, body.n -| 1);
705 const spelling = if (sel < host_table.len) host_table[sel].spec.spelling else "";
706 title = std.fmt.bufPrint(&title_buf, " sessions on {s}", .{spelling}) catch " sessions";
707 } else {
708 pickerRows(&body, host_table, sel, box_w);
709 for (body.host[0..body.n], 0..) |hi, i| {
710 if (hi == sel) sel_row = i;
711 }
712 }
441 // Too small for a box is not too small for an answer: the header alone 713 // Too small for a box is not too small for an answer: the header alone
442 // still says which popup has the keyboard. 714 // still says which popup has the keyboard.
443 const cramped = cols < picker_min_cols or rows < picker_min_rows; 715 const cramped = cols < picker_min_cols or rows < picker_min_rows;
@@ -448,10 +720,6 @@ pub fn paintPicker(shared: *Shared, host_table: []Host, sel: usize, line: ?[]con
448 // selection stays visible, because it is what every other key acts on. 720 // selection stays visible, because it is what every other key acts on.
449 const shown: usize = height -| 2; 721 const shown: usize = height -| 2;
450 var first: usize = 0; 722 var first: usize = 0;
451 var sel_row: usize = 0;
452 for (body.host[0..body.n], 0..) |hi, i| {
453 if (hi == sel) sel_row = i;
454 }
455 if (shown > 0 and sel_row >= shown) first = sel_row - shown + 1; 723 if (shown > 0 and sel_row >= shown) first = sel_row - shown + 1;
456 // The notice wins the footer over the legend and the editor's line: a 724 // The notice wins the footer over the legend and the editor's line: a
457 // refusal just earned cannot wait for the next keystroke. PEEKED, because 725 // refusal just earned cannot wait for the next keystroke. PEEKED, because
@@ -462,8 +730,10 @@ pub fn paintPicker(shared: *Shared, host_table: []Host, sel: usize, line: ?[]con
462 notice 730 notice
463 else if (line) |l| 731 else if (line) |l|
464 l 732 l
733 else if (view.level == .sessions)
734 " Enter add to wall c new session x end Esc back"
465 else 735 else
466 " Enter/c new session x forget a add Esc"; 736 " Enter sessions c new session x forget a add Esc";
467 737
468 var out: [picker_frame_max]u8 = undefined; 738 var out: [picker_frame_max]u8 = undefined;
469 var fbs = std.io.fixedBufferStream(&out); 739 var fbs = std.io.fixedBufferStream(&out);
@@ -471,12 +741,12 @@ pub fn paintPicker(shared: *Shared, host_table: []Host, sel: usize, line: ?[]con
471 // The cursor is hidden for as long as the popup owns the screen: a 741 // The cursor is hidden for as long as the popup owns the screen: a
472 // caret left blinking in a tile says the keys are going there. 742 // caret left blinking in a tile says the keys are going there.
473 wr.writeAll("\x1b[?25l") catch return; 743 wr.writeAll("\x1b[?25l") catch return;
474 pickerLine(wr, top, left, w, " hosts"); 744 pickerLine(wr, top, left, box_w, title);
475 if (!cramped) { 745 if (!cramped) {
476 var r: u16 = 0; 746 var r: u16 = 0;
477 while (r < shown and first + r < body.n) : (r += 1) 747 while (r < shown and first + r < body.n) : (r += 1)
478 pickerLine(wr, top + 1 + r, left, w, body.row(first + r)); 748 pickerLine(wr, top + 1 + r, left, box_w, body.row(first + r));
479 pickerLine(wr, top + height - 1, left, w, foot); 749 pickerLine(wr, top + height - 1, left, box_w, foot);
480 } 750 }
481 const frame = fbs.getWritten(); 751 const frame = fbs.getWritten();
482 // Nothing changed, nothing written. The pollers report once a second 752 // Nothing changed, nothing written. The pollers report once a second
src/tui/wall_pump.zig
Old New
@@ -839,6 +839,23 @@ pub fn pumpTile(t: *Tile) void {
839 painted_gen = snap.gen; 839 painted_gen = snap.gen;
840 } 840 }
841 } 841 }
842 // The goodbye a removed pane is owed. `Ctrl-\ x` sets `detach_req` and
843 // then `removed`, and a busy pump usually leaves the loop on `removed`
844 // before it reaches the detach block inside it — so the frame is
845 // written HERE, on the way out, while `transport` is still open and
846 // this thread still owns it. Without it the daemon frees the slot on
847 // the close instead of on a goodbye, which costs a `mux` typed straight
848 // after the session it just gave back.
849 if (t.detach_req.swap(false, .acq_rel)) transport.writeFrame(.detach, "") catch {};
850 }
851
852 /// The same words with no brackets, for a caller that puts the session's
853 /// name in front of them: the picker's notice is `[NAME: WORDS]`, one pair
854 /// of brackets and not two. Off `endRefusal` rather than a second table, so
855 /// the tile's banner and the popup's footer cannot drift apart.
856 pub fn endRefusalWord(reason: []const u8) []const u8 {
857 const said = endRefusal(reason);
858 return said[1 .. said.len - 1];
842 } 859 }
843 860
844 /// A refusal in THIS client's words: `parseEndReply` hands back the frame's 861 /// A refusal in THIS client's words: `parseEndReply` hands back the frame's
src/tui/wall_test_harness.zig
Old New
@@ -120,10 +120,36 @@ pub fn setList(h: *Host, list: []const u8) void {
120 h.poll.reachable.store(true, .release); 120 h.poll.reachable.store(true, .release);
121 } 121 }
122 122
123 /// A wall of hosts and no tiles: what a test about the BOX rather than the
124 /// panes under it paints from. `hostWall`'s own cell, not `standing_live`,
125 /// so a test that already stood a wall up does not have its count zeroed by
126 /// painting a popup over it.
127 var host_wall_live: usize = 0;
128 var no_tiles: [0]Tile = .{};
129 var no_present: [0]bool = .{};
130
131 pub fn hostWall(shared: *Shared, host_table: []Host) wv.Wall {
132 host_wall_live = 0;
133 return wallOf(std.testing.allocator, &no_tiles, &no_present, &host_wall_live, shared, host_table);
134 }
135
123 // A picker painted into a pipe, drained. Non-blocking on both ends so a 136 // A picker painted into a pipe, drained. Non-blocking on both ends so a
124 // frame that outgrew the pipe FAILS here rather than parking the suite. 137 // frame that outgrew the pipe FAILS here rather than parking the suite.
125 pub fn pickerFrame(shared: *Shared, r: std.posix.fd_t, host_table: []Host, sel: usize, out: []u8) []const u8 { 138 pub fn pickerFrame(shared: *Shared, r: std.posix.fd_t, host_table: []Host, sel: usize, out: []u8) []const u8 {
126 wall_picker.paintPicker(shared, host_table, sel, null); 139 return pickerFrameAt(shared, r, host_table, sel, .{}, out);
140 }
141
142 /// `pickerFrame` at a stated level and row: the session list is a second
143 /// body out of the same box, and the paint is what a test can judge.
144 pub fn pickerFrameAt(
145 shared: *Shared,
146 r: std.posix.fd_t,
147 host_table: []Host,
148 sel: usize,
149 view: wall_picker.PickerView,
150 out: []u8,
151 ) []const u8 {
152 wall_picker.paintPicker(hostWall(shared, host_table), sel, view, null);
127 const n = std.posix.read(r, out) catch 0; 153 const n = std.posix.read(r, out) catch 0;
128 return out[0..n]; 154 return out[0..n];
129 } 155 }
src/tui/wall_test_picker.zig
Old New
@@ -13,6 +13,7 @@ const PickerAuto = wall_picker.PickerAuto;
13 const PickerBody = wall_picker.PickerBody; 13 const PickerBody = wall_picker.PickerBody;
14 const Shared = wv.Shared; 14 const Shared = wv.Shared;
15 const Tile = wv.Tile; 15 const Tile = wv.Tile;
16 const TmpDir = @import("testtmp").TmpDir;
16 const WallScreen = fixture.WallScreen; 17 const WallScreen = fixture.WallScreen;
17 18
18 test "paintPicker: the box is centred on the terminal, not pinned to the origin" { 19 test "paintPicker: the box is centred on the terminal, not pinned to the origin" {
@@ -117,16 +118,16 @@ test "paintPicker: a frame the stamp refuses to write does not eat the notice wi
117 var table = [_]Host{fixture.testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock")}; 118 var table = [_]Host{fixture.testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock")};
118 fixture.setList(&table[0], ""); 119 fixture.setList(&table[0], "");
119 table[0].applied = true; 120 table[0].applied = true;
120 wall_picker.paintPicker(&shared, &table, 0, null); 121 wall_picker.paintPicker(fixture.hostWall(&shared, &table), 0, .{}, null);
121 122
122 // A notice whose footer is byte-for-byte the legend already on the 123 // A notice whose footer is byte-for-byte the legend already on the
123 // screen: the stamp refuses the write, and taking the notice ahead of 124 // screen: the stamp refuses the write, and taking the notice ahead of
124 // that check is a sentence consumed by a frame nobody was sent. 125 // that check is a sentence consumed by a frame nobody was sent.
125 wv.setNotice(&shared, " Enter/c new session x forget a add Esc"); 126 wv.setNotice(&shared, " Enter sessions c new session x forget a add Esc");
126 wall_picker.paintPicker(&shared, &table, 0, null); 127 wall_picker.paintPicker(fixture.hostWall(&shared, &table), 0, .{}, null);
127 var buf: [96]u8 = undefined; 128 var buf: [96]u8 = undefined;
128 try std.testing.expectEqualStrings( 129 try std.testing.expectEqualStrings(
129 " Enter/c new session x forget a add Esc", 130 " Enter sessions c new session x forget a add Esc",
130 wv.takeNotice(&shared, &buf), 131 wv.takeNotice(&shared, &buf),
131 ); 132 );
132 } 133 }
@@ -168,7 +169,7 @@ test "paintPicker: replayed into an engine, the popup covers its box and NOT one
168 fixture.setList(h, ""); 169 fixture.setList(h, "");
169 h.applied = true; 170 h.applied = true;
170 } 171 }
171 wall_picker.paintPicker(&shared, &table, 1, null); 172 wall_picker.paintPicker(fixture.hostWall(&shared, &table), 1, .{}, null);
172 screen.drain(); 173 screen.drain();
173 const dump = try screen.eng.dumpPlain(alloc); 174 const dump = try screen.eng.dumpPlain(alloc);
174 defer alloc.free(dump); 175 defer alloc.free(dump);
@@ -758,7 +759,7 @@ test "closeAsk: the picker comes back when the prompt box leaves, whichever end
758 shared.ask_open.store(true, .release); 759 shared.ask_open.store(true, .release);
759 screen.drain(); 760 screen.drain();
760 761
761 wv.closeAsk(w, &shared, &prefix, 0); 762 wv.closeAsk(w, &shared, &prefix, 0, 0);
762 screen.drain(); 763 screen.drain();
763 const dump = try screen.eng.dumpPlain(alloc); 764 const dump = try screen.eng.dumpPlain(alloc);
764 defer alloc.free(dump); 765 defer alloc.free(dump);
@@ -778,10 +779,316 @@ test "closeAsk: the picker comes back when the prompt box leaves, whichever end
778 shared.ask_open.store(true, .release); 779 shared.ask_open.store(true, .release);
779 wall_picker.paintAsk(&shared, "box's password: ", &prefix); 780 wall_picker.paintAsk(&shared, "box's password: ", &prefix);
780 screen.drain(); 781 screen.drain();
781 wv.closeAsk(w, &shared, &prefix, 0); 782 wv.closeAsk(w, &shared, &prefix, 0, 0);
782 screen.drain(); 783 screen.drain();
783 const after = try screen.eng.dumpPlain(alloc); 784 const after = try screen.eng.dumpPlain(alloc);
784 defer alloc.free(after); 785 defer alloc.free(after);
785 try std.testing.expect(std.mem.indexOf(u8, after, "/tmp/b.sock") == null); 786 try std.testing.expect(std.mem.indexOf(u8, after, "/tmp/b.sock") == null);
786 try std.testing.expect(std.mem.indexOf(u8, after, "password") == null); 787 try std.testing.expect(std.mem.indexOf(u8, after, "password") == null);
787 } 788 }
789
790 test "sessionRows: a host's sessions, marked when already on this wall, with the holder count when the daemon says" {
791 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
792 defer shared.tree.deinit();
793 var hosts_table = [_]Host{fixture.testHost(&shared, "box", "/b")};
794 fixture.setList(&hosts_table[0], "0\nwork\n# holds 0 2\n# holds work 1\n# mux 0.0.1-18");
795 // Two panes, because a fixture with one is blind to a lookup that
796 // aliased every session onto the first tile it walked.
797 var tiles = [_]Tile{ fixture.claimBench(&shared, 0), fixture.claimBench(&shared, 1) };
798 tiles[0].host = 0;
799 tiles[0].r.session = "work";
800 tiles[1].host = 0;
801 tiles[1].r.session = "elsewhere";
802 var present = [_]bool{ true, true };
803 var live: usize = 2;
804 const w = fixture.wallOf(std.testing.allocator, &tiles, &present, &live, &shared, &hosts_table);
805
806 var body = wall_picker.PickerBody{};
807 wall_picker.sessionRows(&body, w, 0, 1, 80);
808 try std.testing.expectEqual(@as(usize, 2), body.n);
809 try std.testing.expect(std.mem.indexOf(u8, body.row(0), " 0 ") != null);
810 try std.testing.expect(std.mem.indexOf(u8, body.row(0), "2 clients") != null);
811 // Session 0 has no pane, so it is not marked; `work` does.
812 try std.testing.expect(std.mem.indexOf(u8, body.row(0), "on this wall") == null);
813 try std.testing.expect(std.mem.indexOf(u8, body.row(1), "work") != null);
814 try std.testing.expect(std.mem.indexOf(u8, body.row(1), "on this wall") != null);
815 // One holder is singular: the count is read as a number of people.
816 try std.testing.expect(std.mem.indexOf(u8, body.row(1), "1 client,") == null);
817 try std.testing.expect(std.mem.indexOf(u8, body.row(1), "1 client") != null);
818 // `sel_row` marks the row, not the host: the second one is selected.
819 try std.testing.expect(std.mem.indexOf(u8, body.row(1), "> ") != null);
820 try std.testing.expect(std.mem.indexOf(u8, body.row(0), "> ") == null);
821 }
822
823 test "sessionRows: a pane on ANOTHER host does not mark this host's same-named session" {
824 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
825 defer shared.tree.deinit();
826 var hosts_table = [_]Host{
827 fixture.testHost(&shared, "box", "/b"),
828 fixture.testHost(&shared, "other", "/o"),
829 };
830 fixture.setList(&hosts_table[0], "work\n");
831 fixture.setList(&hosts_table[1], "work\n");
832 var tiles = [_]Tile{fixture.claimBench(&shared, 0)};
833 tiles[0].host = 1;
834 tiles[0].r.session = "work";
835 var present = [_]bool{true};
836 var live: usize = 1;
837 const w = fixture.wallOf(std.testing.allocator, &tiles, &present, &live, &shared, &hosts_table);
838
839 var body = wall_picker.PickerBody{};
840 wall_picker.sessionRows(&body, w, 0, 0, 80);
841 try std.testing.expectEqual(@as(usize, 1), body.n);
842 try std.testing.expect(std.mem.indexOf(u8, body.row(0), "on this wall") == null);
843 wall_picker.sessionRows(&body, w, 1, 0, 80);
844 try std.testing.expect(std.mem.indexOf(u8, body.row(0), "on this wall") != null);
845 }
846
847 test "sessionRows: an old daemon's list shows no count rather than zero" {
848 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
849 defer shared.tree.deinit();
850 var hosts_table = [_]Host{fixture.testHost(&shared, "box", "/b")};
851 fixture.setList(&hosts_table[0], "0\n");
852 const w = fixture.hostWall(&shared, &hosts_table);
853 var body = wall_picker.PickerBody{};
854 wall_picker.sessionRows(&body, w, 0, 0, 80);
855 try std.testing.expectEqual(@as(usize, 1), body.n);
856 // Never "0 clients": a daemon that cannot count said nothing, and a
857 // zero would read as a session nobody is in.
858 try std.testing.expect(std.mem.indexOf(u8, body.row(0), "client") == null);
859 }
860
861 test "sessionCount and rowStep: the row walks the host's own list and wraps" {
862 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
863 defer shared.tree.deinit();
864 var hosts_table = [_]Host{fixture.testHost(&shared, "box", "/b")};
865 fixture.setList(&hosts_table[0], "0\na\nb\n# holds 0 1");
866 try std.testing.expectEqual(@as(usize, 3), wall_picker.sessionCount(&hosts_table, 0));
867 // A selection with no host under it — an empty hosts file leaves one —
868 // counts zero rather than faulting.
869 try std.testing.expectEqual(@as(usize, 0), wall_picker.sessionCount(&hosts_table, 7));
870 try std.testing.expectEqual(@as(usize, 1), wall_picker.rowStep(0, 1, 3));
871 try std.testing.expectEqual(@as(usize, 0), wall_picker.rowStep(2, 1, 3));
872 try std.testing.expectEqual(@as(usize, 2), wall_picker.rowStep(0, -1, 3));
873 // An empty list has no row to rest on.
874 try std.testing.expectEqual(@as(usize, 0), wall_picker.rowStep(4, 1, 0));
875 }
876
877 test "pickAdd: a listed session becomes a pane once; a second add zooms to it" {
878 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
879 defer arena.deinit();
880 const alloc = arena.allocator();
881 var shared: Shared = undefined;
882 fixture.stoppedWall(alloc, &shared);
883 shared.size = .{ .cols = 120, .rows = 40 };
884 var hosts_table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
885 fixture.setList(&hosts_table[0], "0\nwork\n");
886 var tiles: [wv.max_tiles]Tile = undefined;
887 var present = [_]bool{false} ** wv.max_tiles;
888 var live: usize = 0;
889 defer fixture.endPumps(tiles[0..live]);
890 const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &hosts_table);
891
892 const first = wall_picker.pickAdd(w, 0, 1).?;
893 try std.testing.expectEqualStrings("work", tiles[first].r.session);
894 // JOINED, never created: the session is already on that daemon, and a
895 // create would take a name the daemon has.
896 try std.testing.expect(!tiles[first].creates);
897 try std.testing.expectEqual(@as(?usize, 0), tiles[first].host);
898 try std.testing.expectEqual(@as(usize, 1), wv.presentCount(w.livePresent()));
899
900 const again = wall_picker.pickAdd(w, 0, 1).?;
901 try std.testing.expectEqual(first, again);
902 try std.testing.expectEqual(@as(usize, 1), wv.presentCount(w.livePresent()));
903 try std.testing.expectEqual(first, shared.sel);
904
905 // A row the list does not have adds nothing, and says so.
906 try std.testing.expectEqual(@as(?usize, null), wall_picker.pickAdd(w, 0, 9));
907 var buf: [96]u8 = undefined;
908 try std.testing.expect(std.mem.indexOf(u8, wv.takeNotice(&shared, &buf), "no session") != null);
909 }
910
911 test "paintPicker: the session level names the host and lists its sessions" {
912 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
913 defer std.posix.close(pipe[0]);
914 defer std.posix.close(pipe[1]);
915 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 100, .rows = 40 }, .is_tty = true };
916 defer shared.tree.deinit();
917 var table = [_]Host{
918 fixture.testHost(&shared, "box", "/tmp/a.sock"),
919 fixture.testHost(&shared, "other", "/tmp/b.sock"),
920 };
921 for (&table) |*h| h.applied = true;
922 fixture.setList(&table[1], "0\nwork\n# holds 0 1");
923 fixture.setList(&table[0], "only\n");
924 var buf: [8192]u8 = undefined;
925 // Host 1, not host 0: an off-origin selection is the baseline, so a
926 // paint that read the FIRST host's list would be caught.
927 const frame = fixture.pickerFrameAt(&shared, pipe[0], &table, 1, .{ .level = .sessions, .row = 1 }, &buf);
928 try std.testing.expect(std.mem.indexOf(u8, frame, "sessions on other") != null);
929 try std.testing.expect(std.mem.indexOf(u8, frame, "work") != null);
930 try std.testing.expect(std.mem.indexOf(u8, frame, "1 client") != null);
931 // The other host's session is not in this box.
932 try std.testing.expect(std.mem.indexOf(u8, frame, "only") == null);
933 // The footer is the session level's keys, not the host level's.
934 try std.testing.expect(std.mem.indexOf(u8, frame, "x end") != null);
935 try std.testing.expect(std.mem.indexOf(u8, frame, "x forget") == null);
936 }
937
938 test "Shared.PickEnd: the second x forces only on the same host and row, inside the window" {
939 var end: wv.PickEnd = .{};
940 try std.testing.expect(!end.armedFor(0, "work", 1000));
941 end.arm(0, "work", 1000 + wv.end_arm_ms);
942 try std.testing.expect(end.armedFor(0, "work", 1500));
943 // A different row is a FIRST press: the count the user read was about
944 // another session.
945 try std.testing.expect(!end.armedFor(0, "other", 1500));
946 // ...and so is the same name on another daemon.
947 try std.testing.expect(!end.armedFor(1, "work", 1500));
948 // The window closes on the clock.
949 try std.testing.expect(!end.armedFor(0, "work", 1000 + wv.end_arm_ms + 1));
950 // An accepted end disarms: the window must not outlive its session.
951 end.arm(0, "work", 1000 + wv.end_arm_ms);
952 end.clear();
953 try std.testing.expect(!end.armedFor(0, "work", 1500));
954 }
955
956 /// A daemon stand-in for `pickEnd`: one connection per press, each answering
957 /// one `end_req` with a scripted verdict and recording the `force` bit that
958 /// arrived. A REAL socket, because `pickEnd`'s whole job is the side
959 /// connection — the verdicts are the daemon's to make and are pinned against
960 /// a real one in `server_test_session.zig`.
961 const EndFake = struct {
962 listener: std.net.Server,
963 replies: []const proto.EndReply,
964 force: [4]bool = @splat(false),
965 n: usize = 0,
966
967 fn serve(self: *EndFake) void {
968 const alloc = std.testing.allocator;
969 while (self.n < self.replies.len) {
970 const conn = self.listener.accept() catch return;
971 defer conn.stream.close();
972 const f = (proto.readFrame(alloc, conn.stream.handle) catch return) orelse return;
973 defer f.deinit(alloc);
974 if (f.type != .end_req or f.payload.len < proto.end_req_len) return;
975 const at = self.n;
976 self.force[at] = f.payload[0] != 0;
977 self.n += 1;
978 const r = self.replies[at];
979 var buf: [proto.end_reply_max_len]u8 = undefined;
980 proto.writeFrame(
981 conn.stream.handle,
982 .end_reply,
983 proto.encodeEndReply(&buf, r.accepted, r.others, r.reason),
984 ) catch return;
985 }
986 }
987 };
988
989 test "pickEnd: the first x arms with the count, the second forces, and a refusal that is not others-attached arms nothing" {
990 const alloc = std.testing.allocator;
991 var tmp = try TmpDir.make();
992 defer tmp.cleanup();
993 const sp = try std.fmt.allocPrint(alloc, "{s}/end.sock", .{tmp.path()});
994 defer alloc.free(sp);
995 const addr = try std.net.Address.initUnix(sp);
996 var fake = EndFake{
997 .listener = try addr.listen(.{}),
998 .replies = &.{
999 .{ .accepted = false, .others = 2, .reason = proto.end_reason.others_attached },
1000 .{ .accepted = true, .others = 0, .reason = proto.end_reason.accepted },
1001 .{ .accepted = false, .others = 0, .reason = proto.end_reason.no_session },
1002 },
1003 };
1004 defer fake.listener.deinit();
1005 const th = try std.Thread.spawn(.{}, EndFake.serve, .{&fake});
1006
1007 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
1008 defer shared.tree.deinit();
1009 // TWO hosts, and the daemon on the SECOND: a one-host fixture cannot
1010 // see a press that reached for the wrong row's machine. Host 0's socket
1011 // has nothing on it, so a misdirected press fails loudly.
1012 var table = [_]Host{
1013 fixture.testHost(&shared, "nowhere", "/tmp/mux-no-such-socket"),
1014 fixture.testHost(&shared, "box", sp),
1015 };
1016 // The two recipes deliberately DIFFER, and only the poller's reaches the
1017 // fake: an end must ride the batch recipe (`BatchMode`, a connect
1018 // timeout, `asked` false) and never the interactive one that would
1019 // prompt on /dev/tty under the popup and start a daemon.
1020 table[1].spec.target = .{ .sock = "/tmp/mux-no-such-socket-entry" };
1021 // The holds lines are what say this daemon is new enough to be asked.
1022 fixture.setList(&table[1], "0\nwork\n# holds 0 1\n# holds work 3");
1023 const w = fixture.hostWall(&shared, &table);
1024 var buf: [96]u8 = undefined;
1025
1026 // First press: refused with the count, and the window is armed on THIS
1027 // host and THIS name.
1028 wall_picker.pickEnd(w, 1, 1, 1000);
1029 try std.testing.expect(shared.pick_end.armedFor(1, "work", 1500));
1030 try std.testing.expect(!shared.pick_end.armedFor(1, "0", 1500));
1031 try std.testing.expect(!shared.pick_end.armedFor(0, "work", 1500));
1032 const first = wv.takeNotice(&shared, &buf);
1033 try std.testing.expect(std.mem.indexOf(u8, first, "work") != null);
1034 // The DAEMON's verdict (2 others), not the row's holder count (3): the
1035 // `# holds` line counts every holder including the asker, and the two
1036 // numbers are deliberately different in this fixture so a notice built
1037 // from the wrong one would be caught.
1038 try std.testing.expect(std.mem.indexOf(u8, first, "2 others attached") != null);
1039 try std.testing.expect(std.mem.indexOf(u8, first, "3 other") == null);
1040 try std.testing.expect(std.mem.indexOf(u8, first, "x again to end") != null);
1041
1042 // Second press inside the window: forced, accepted, and disarmed.
1043 wall_picker.pickEnd(w, 1, 1, 1500);
1044 try std.testing.expect(!shared.pick_end.armedFor(1, "work", 1600));
1045 const second = wv.takeNotice(&shared, &buf);
1046 try std.testing.expect(std.mem.indexOf(u8, second, "ending work on box") != null);
1047
1048 // A refusal that is NOT others-attached: the daemon's reason, in this
1049 // client's words, and no arm — a next `x` must not force an end on a
1050 // session the daemon just said it has not got.
1051 wall_picker.pickEnd(w, 1, 1, 5000);
1052 try std.testing.expect(!shared.pick_end.armedFor(1, "work", 5100));
1053 const third = wv.takeNotice(&shared, &buf);
1054 try std.testing.expect(std.mem.indexOf(u8, third, "work") != null);
1055 try std.testing.expect(std.mem.indexOf(u8, third, "no such session") != null);
1056 try std.testing.expect(std.mem.indexOf(u8, third, "x again") == null);
1057 th.join();
1058
1059 // Three presses, three connections — and the force bit is the SECOND
1060 // press and nothing else.
1061 try std.testing.expectEqual(@as(usize, 3), fake.n);
1062 try std.testing.expectEqual([3]bool{ false, true, false }, fake.force[0..3].*);
1063 }
1064
1065 test "pickEnd: a daemon that counts no holders is too old to end a session, and is not dialled" {
1066 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
1067 defer shared.tree.deinit();
1068 var table = [_]Host{
1069 fixture.testHost(&shared, "new", "/tmp/mux-no-such-socket-a"),
1070 fixture.testHost(&shared, "old", "/tmp/mux-no-such-socket-b"),
1071 };
1072 fixture.setList(&table[0], "0\n# holds 0 1");
1073 // No `# holds` line anywhere: the counting and the `end_req` arm shipped
1074 // together, so this daemon would answer the press with silence.
1075 fixture.setList(&table[1], "0\nwork\n");
1076 const w = fixture.hostWall(&shared, &table);
1077 var buf: [96]u8 = undefined;
1078 wall_picker.pickEnd(w, 1, 0, 1000);
1079 // The pump's own wording for the same fact about the box, and no arm:
1080 // nothing was asked, so there is nothing to press again for.
1081 try std.testing.expectEqualStrings("[daemon too old to end a session]", wv.takeNotice(&shared, &buf));
1082 try std.testing.expect(!shared.pick_end.armedFor(1, "0", 1500));
1083 }
1084
1085 test "pickEnd: a row the list does not have asks nothing" {
1086 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
1087 defer shared.tree.deinit();
1088 var table = [_]Host{fixture.testHost(&shared, "box", "/tmp/mux-no-such-socket-c")};
1089 fixture.setList(&table[0], "0\n# holds 0 1");
1090 const w = fixture.hostWall(&shared, &table);
1091 var buf: [96]u8 = undefined;
1092 wall_picker.pickEnd(w, 0, 4, 1000);
1093 try std.testing.expectEqualStrings("[no session on that row]", wv.takeNotice(&shared, &buf));
1094 }
src/tui/wallview.zig
Old New
@@ -167,6 +167,10 @@ pub const Shared = struct {
167 /// The popup's last frame, so an unchanged one is not rewritten. 167 /// The popup's last frame, so an unchanged one is not rewritten.
168 /// Keyboard-thread only, like the picker itself. 168 /// Keyboard-thread only, like the picker itself.
169 picker_stamp: u64 = 0, 169 picker_stamp: u64 = 0,
170 /// The picker's end two-step, per host and name: a second `x` on the SAME
171 /// row inside the window forces, any other row is a first press.
172 /// Keyboard-thread only, like the picker itself.
173 pick_end: PickEnd = .{},
170 /// True while ssh's prompt box is on the terminal. `picker_open`'s 174 /// True while ssh's prompt box is on the terminal. `picker_open`'s
171 /// twin, for its reason and at the same gates — and a second flag 175 /// twin, for its reason and at the same gates — and a second flag
172 /// rather than a mode of the first, because a prompt ARRIVES and can 176 /// rather than a mode of the first, because a prompt ARRIVES and can
@@ -191,6 +195,29 @@ pub const Shared = struct {
191 } 195 }
192 }; 196 };
193 197
198 /// The picker's `x`, which is a two-step the client only REMEMBERS: the
199 /// daemon refuses the first press with a count, and the second inside the
200 /// window says the user meant it. Keyed by host AND name because the same
201 /// session name lives on two daemons, and because a press that moved to
202 /// another row is a question about another session — it must be a first
203 /// press, not a force inherited from the row above.
204 pub const PickEnd = struct {
205 host: usize = 0,
206 name: client.SessionName = .{},
207 until: i64 = 0,
208 pub fn armedFor(self: *const PickEnd, host: usize, name: []const u8, now: i64) bool {
209 return now < self.until and self.host == host and std.mem.eql(u8, self.name.slice(), name);
210 }
211 pub fn arm(self: *PickEnd, host: usize, name: []const u8, until: i64) void {
212 self.host = host;
213 self.name = client.SessionName.of(name);
214 self.until = until;
215 }
216 pub fn clear(self: *PickEnd) void {
217 self.until = 0;
218 }
219 };
220
194 /// Opens the prompt box on whatever ssh is asking, if anything is. False 221 /// Opens the prompt box on whatever ssh is asking, if anything is. False
195 /// when there is nothing to show or a box is already up: `take` is the 222 /// when there is nothing to show or a box is already up: `take` is the
196 /// transition, so a doorbell rung twice opens one popup. 223 /// transition, so a doorbell rung twice opens one popup.
@@ -208,6 +235,7 @@ pub fn closeAsk(
208 shared: *Shared, 235 shared: *Shared,
209 prefix: *interact.PrefixFilter, 236 prefix: *interact.PrefixFilter,
210 picker_sel: usize, 237 picker_sel: usize,
238 picker_row: usize,
211 ) void { 239 ) void {
212 // ONE function: `relayout` CLEARS the screen, so a picker still open 240 // ONE function: `relayout` CLEARS the screen, so a picker still open
213 // under the box owes a repaint. The answer buffer is zeroed here and 241 // under the box owes a repaint. The answer buffer is zeroed here and
@@ -220,7 +248,7 @@ pub fn closeAsk(
220 if (!prefix.picking) return; 248 if (!prefix.picking) return;
221 var foot: [interact.PrefixFilter.prompt_max + 4]u8 = undefined; 249 var foot: [interact.PrefixFilter.prompt_max + 4]u8 = undefined;
222 const back = wall_picker.pickerRepaint(&foot, prefix, true); 250 const back = wall_picker.pickerRepaint(&foot, prefix, true);
223 wall_picker.paintPicker(shared, w.hosts, picker_sel, back.line); 251 wall_picker.paintPicker(w, picker_sel, .{ .level = prefix.pick_level, .row = picker_row }, back.line);
224 } 252 }
225 253
226 /// The host picker gives the terminal back — the key that closed it, or the 254 /// The host picker gives the terminal back — the key that closed it, or the
@@ -238,6 +266,10 @@ fn closePicker(
238 // popup was painted over and bumps `repaint_gen` — the only thing that 266 // popup was painted over and bumps `repaint_gen` — the only thing that
239 // redraws a tile whose session said nothing while the box was up. 267 // redraws a tile whose session said nothing while the box was up.
240 prefix.picking = false; 268 prefix.picking = false;
269 // The level goes home with the box, whichever end closed it: a popup
270 // reopened after an auto-close must be a HOST list, and only the keys
271 // that close it themselves put the level back.
272 prefix.pick_level = .hosts;
241 shown.* = false; 273 shown.* = false;
242 w.shared.picker_open.store(false, .release); 274 w.shared.picker_open.store(false, .release);
243 // The screen under the box is about to be redrawn, so the next open owes 275 // The screen under the box is about to be redrawn, so the next open owes
@@ -895,8 +927,11 @@ pub fn vanishTile(tiles: []Tile, present: []bool, shared: *Shared, i: usize, to:
895 /// `Ctrl-\ x`: this pane leaves THIS wall. The session is the daemon's and 927 /// `Ctrl-\ x`: this pane leaves THIS wall. The session is the daemon's and
896 /// keeps running for whoever else holds it; ending one is the picker's 928 /// keeps running for whoever else holds it; ending one is the picker's
897 /// job, beside the count of who else is there. The pump is told to say 929 /// job, beside the count of who else is there. The pump is told to say
898 /// goodbye (`detach_req`) so the daemon frees the slot now rather than at 930 /// goodbye (`detach_req`): it writes `.detach` on its way out — the last
899 /// a timeout, then the tile is vanished and the layout written without it. 931 /// thing `pumpTile` does past its loop — so the daemon frees the slot on a
932 /// goodbye rather than at a timeout, and the transport close behind it is
933 /// the fallback for a pump that ended some other way. Then the tile is
934 /// vanished and the layout written without it.
900 pub fn removePane(w: Wall, z: usize) void { 935 pub fn removePane(w: Wall, z: usize) void {
901 if (z >= w.live.* or !w.present[z]) return; 936 if (z >= w.live.* or !w.present[z]) return;
902 const t = &w.tiles[z]; 937 const t = &w.tiles[z];
@@ -1892,6 +1927,11 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1892 // popup reopened by reflex is where it was left. Keyboard-thread 1927 // popup reopened by reflex is where it was left. Keyboard-thread
1893 // state, like the focus. 1928 // state, like the focus.
1894 var picker_sel: usize = 0; 1929 var picker_sel: usize = 0;
1930 // Which SESSION row the picker's second level rests on, as an index into
1931 // `picker_sel`'s host list. Reset on every level change rather than kept
1932 // like `picker_sel`: a row number means nothing on another host's list,
1933 // and the daemons answer while the box is open.
1934 var picker_row: usize = 0;
1895 // The prompt on screen, kept because the box is repainted on every 1935 // The prompt on screen, kept because the box is repainted on every
1896 // keystroke and the question does not come round again. 1936 // keystroke and the question does not come round again.
1897 var ask_prompt: askpass.Prompt = .{}; 1937 var ask_prompt: askpass.Prompt = .{};
@@ -2037,7 +2077,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2037 // nobody waits on. Dismissing it by key would decline a live dial. 2077 // nobody waits on. Dismissing it by key would decline a live dial.
2038 if (input.prefix.asking) { 2078 if (input.prefix.asking) {
2039 if (shared.prompts) |l| { 2079 if (shared.prompts) |l| {
2040 if (!l.showing()) closeAsk(w, &shared, &input.prefix, picker_sel); 2080 if (!l.showing()) closeAsk(w, &shared, &input.prefix, picker_sel, picker_row);
2041 } 2081 }
2042 } 2082 }
2043 // Every pass, not only on the bell: `endedTile`'s reason twice 2083 // Every pass, not only on the bell: `endedTile`'s reason twice
@@ -2086,6 +2126,10 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2086 picker_opened = true; 2126 picker_opened = true;
2087 picker_shown = true; 2127 picker_shown = true;
2088 input.prefix.picking = true; 2128 input.prefix.picking = true;
2129 // The wall's own open is a HOST list: the level is the
2130 // filter's, and nothing else here would put it back.
2131 input.prefix.pick_level = .hosts;
2132 picker_row = 0;
2089 picker_sel = wall_picker.pickerNearest(w.hosts, picker_sel); 2133 picker_sel = wall_picker.pickerNearest(w.hosts, picker_sel);
2090 }, 2134 },
2091 .close => closePicker(w, &input.prefix, &picker_shown, null), 2135 .close => closePicker(w, &input.prefix, &picker_shown, null),
@@ -2095,10 +2139,16 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2095 // popup is open, but a repaint every 100ms rewrites an unchanged 2139 // popup is open, but a repaint every 100ms rewrites an unchanged
2096 // screen. A zeroed stamp is a screen the box is no longer on — 2140 // screen. A zeroed stamp is a screen the box is no longer on —
2097 // `relayout` cleared it, and nothing else would repaint the popup. 2141 // `relayout` cleared it, and nothing else would repaint the popup.
2142 // The lists move under the box: a poll that ended a session while
2143 // the popup was open leaves the selection past the last row, and
2144 // the highlight would be on nothing.
2145 if (input.prefix.pick_level == .sessions)
2146 picker_row = @min(picker_row, wall_picker.sessionCount(w.hosts, picker_sel) -| 1);
2098 var foot_buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined; 2147 var foot_buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined;
2099 const repair = wall_picker.pickerRepaint(&foot_buf, &input.prefix, picker_opened or host_news or 2148 const repair = wall_picker.pickerRepaint(&foot_buf, &input.prefix, picker_opened or host_news or
2100 winch or fds[1].revents != 0 or shared.picker_stamp == 0); 2149 winch or fds[1].revents != 0 or shared.picker_stamp == 0);
2101 if (repair.due) wall_picker.paintPicker(&shared, w.hosts, picker_sel, repair.line); 2150 if (repair.due)
2151 wall_picker.paintPicker(w, picker_sel, .{ .level = input.prefix.pick_level, .row = picker_row }, repair.line);
2102 if (fds[0].revents == 0) continue; 2152 if (fds[0].revents == 0) continue;
2103 2153
2104 const n = std.posix.read(stdin_fd, &b) catch break; 2154 const n = std.posix.read(stdin_fd, &b) catch break;
@@ -2141,7 +2191,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2141 wall_picker.paintAsk(&shared, ask_prompt.slice(), &input.prefix); 2191 wall_picker.paintAsk(&shared, ask_prompt.slice(), &input.prefix);
2142 continue; 2192 continue;
2143 } 2193 }
2144 closeAsk(w, &shared, &input.prefix, picker_sel); 2194 closeAsk(w, &shared, &input.prefix, picker_sel, picker_row);
2145 continue; 2195 continue;
2146 } 2196 }
2147 // The picker is a MODE: while it is open every key is the popup's, 2197 // The picker is a MODE: while it is open every key is the popup's,
@@ -2161,15 +2211,51 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2161 if (tiles[z].host) |hi| picker_sel = hi; 2211 if (tiles[z].host) |hi| picker_sel = hi;
2162 } 2212 }
2163 picker_sel = wall_picker.pickerNearest(w.hosts, picker_sel); 2213 picker_sel = wall_picker.pickerNearest(w.hosts, picker_sel);
2214 picker_row = 0;
2164 } 2215 }
2216 // The level the FILTER left, not the one the key arrived at: it
2217 // flips `pick_level` before returning, so `.sessions` on a
2218 // `.pick_enter` means the list just opened and `.hosts` means a
2219 // session was chosen and the popup is closing.
2220 const level = input.prefix.pick_level;
2165 switch (cmd.action) { 2221 switch (cmd.action) {
2166 .pick_move => |d| picker_sel = wall_picker.pickerStep(w.hosts, picker_sel, d), 2222 .pick_move => |d| if (level == .sessions) {
2167 .pick_select => |row| { 2223 picker_row = wall_picker.rowStep(picker_row, d, wall_picker.sessionCount(w.hosts, picker_sel));
2224 } else {
2225 picker_sel = wall_picker.pickerStep(w.hosts, picker_sel, d);
2226 },
2227 .pick_select => |row| if (level == .sessions) {
2228 picker_row = @min(row - 1, wall_picker.sessionCount(w.hosts, picker_sel) -| 1);
2229 } else {
2168 if (wall_picker.pickerAt(w.hosts, row - 1)) |hi| picker_sel = hi; 2230 if (wall_picker.pickerAt(w.hosts, row - 1)) |hi| picker_sel = hi;
2169 }, 2231 },
2232 .pick_enter => if (level == .sessions) {
2233 // Enter at the host level: the session list just opened,
2234 // and there is nothing to do but paint it from the top.
2235 picker_row = 0;
2236 // ...unless there is no host under the selection at all.
2237 // An empty hosts file leaves `picker_sel` at 0 with no
2238 // row there, and a level with nothing in it is a box the
2239 // user has to Esc back out of for nothing.
2240 if (picker_sel >= w.hosts.len or w.hosts[picker_sel].forgotten.load(.acquire)) {
2241 input.prefix.pick_level = .hosts;
2242 setNotice(&shared, "[no host to list sessions on - a adds one]");
2243 }
2244 } else {
2245 // Enter at the session level: the popup closed on a
2246 // choice, and that session becomes a pane.
2247 birth_at = wall_picker.pickAdd(w, picker_sel, picker_row);
2248 },
2249 // Back at the hosts, so the row means nothing any more.
2250 .pick_back => picker_row = 0,
2251 .pick_end => wall_picker.pickEnd(w, picker_sel, picker_row, std.time.milliTimestamp()),
2170 .pick_birth => birth_at = wall_picker.pickBirth(w, picker_sel), 2252 .pick_birth => birth_at = wall_picker.pickBirth(w, picker_sel),
2171 .pick_forget => wall_picker.pickForget(w, picker_sel, hosts_path), 2253 .pick_forget => wall_picker.pickForget(w, picker_sel, hosts_path),
2172 .add_tile => |spelling| { 2254 .add_tile => |spelling| {
2255 // The editor adds a HOST, so its answer belongs on the
2256 // host list — whichever level the `a` was typed at.
2257 input.prefix.pick_level = .hosts;
2258 picker_row = 0;
2173 switch (wall_host.addHost( 2259 switch (wall_host.addHost(
2174 alloc, 2260 alloc,
2175 &shared, 2261 &shared,
@@ -2188,10 +2274,14 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2188 if (th) |handle| handle.detach(); 2274 if (th) |handle| handle.detach();
2189 // The row the user just made is the row they meant. 2275 // The row the user just made is the row they meant.
2190 picker_sel = hi; 2276 picker_sel = hi;
2277 picker_row = 0;
2191 }, 2278 },
2192 // The row it is already on is the row they meant too: 2279 // The row it is already on is the row they meant too:
2193 // an unchanged popup is the prompt saying nothing. 2280 // an unchanged popup is the prompt saying nothing.
2194 .listed => |hi| picker_sel = hi, 2281 .listed => |hi| {
2282 picker_sel = hi;
2283 picker_row = 0;
2284 },
2195 .refused => {}, 2285 .refused => {},
2196 } 2286 }
2197 }, 2287 },
@@ -2201,7 +2291,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2201 if (input.prefix.picking) { 2291 if (input.prefix.picking) {
2202 var line_buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined; 2292 var line_buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined;
2203 const keyed = wall_picker.pickerRepaint(&line_buf, &input.prefix, true); 2293 const keyed = wall_picker.pickerRepaint(&line_buf, &input.prefix, true);
2204 wall_picker.paintPicker(&shared, w.hosts, picker_sel, keyed.line); 2294 wall_picker.paintPicker(w, picker_sel, .{ .level = input.prefix.pick_level, .row = picker_row }, keyed.line);
2205 } else { 2295 } else {
2206 // A close the USER asked for: the auto-open no longer owns 2296 // A close the USER asked for: the auto-open no longer owns
2207 // this popup, so the next tile to arrive cannot force-close 2297 // this popup, so the next tile to arrive cannot force-close
@@ -2285,6 +2375,9 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2285 .pick_forget, 2375 .pick_forget,
2286 .pick_add_open, 2376 .pick_add_open,
2287 .pick_close, 2377 .pick_close,
2378 .pick_enter,
2379 .pick_back,
2380 .pick_end,
2288 => {}, 2381 => {},
2289 // ssh's own keys, answered above this switch for the reason 2382 // ssh's own keys, answered above this switch for the reason
2290 // the picker's are: the box is the only thing on the screen 2383 // the picker's are: the box is the only thing on the screen