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
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 |