src/tui/wall_picker.zig
Ref: Size: 43.1 KiB History
//! `Ctrl-\ s`: the host picker's popup. A MODE of the wall's prefix filter, so
//! every byte typed here is the popup's and none reaches a session. Rows are
//! the hosts file's daemons in file order with the poller's last answer beside
//! each; Enter opens that host's SESSIONS, `c` births, `x` forgets, `a` is the
//! spelling editor. The session level is the second list: Enter adds one as a
//! pane, `c` still births, `x` ends it through the daemon's two-step, and Esc
//! goes back to the hosts rather than closing the box.
const std = @import("std");
const proto = @import("term").protocol;
const client = @import("client");
const hosts = @import("client").hosts;
const handoff = @import("client").handoff;
const interact = @import("interact.zig");
const askpass = @import("client").askpass;
const wall_host = @import("wall_host.zig");
const wall_layout = @import("wall_layout.zig");
const wall_pump = @import("wall_pump.zig");
const wv = @import("wallview.zig");
const Host = wall_host.Host;
const Shared = wv.Shared;
const Wall = wv.Wall;
/// The widest row the picker draws. A spelling past it is cut, never
/// wrapped: a host list that reflows is one a digit cannot address.
pub const picker_row_max: usize = 128;
/// Under either, the popup is one row: a box that does not fit is worse
/// than a header saying which one it is.
const picker_min_cols: u16 = 24;
const picker_min_rows: u16 = 4;
/// Room for the clears a shrunken box owes the rows it no longer covers:
/// one cursor address and one ECH per row of the box that was there, whose
/// height is capped the same way this one's is, plus the SGR reset the run
/// opens with.
const picker_clear_max: usize = (wv.max_tiles + 2) * 24 + 4;
/// One whole popup, written in one go: every row plus its cursor address
/// and its two SGRs, at the table's cap of rows, and the clears in front of
/// it — a resize can put the old box and the new one on disjoint rows, so
/// the two heights are budgeted separately rather than shared.
const picker_frame_max: usize = (wv.max_tiles + 2) * (picker_row_max + 32) + 8 + picker_clear_max;
/// The picker's rendered rows. Rendered once per paint and read twice —
/// by the writer and by the tests — so the box on the screen is the box
/// the claims are made about.
pub const PickerBody = struct {
text: [wv.max_tiles][picker_row_max]u8 = undefined,
lens: [wv.max_tiles]usize = [_]usize{0} ** wv.max_tiles,
/// Which host each row is, so a digit answers in host indices.
host: [wv.max_tiles]usize = [_]usize{0} ** wv.max_tiles,
n: usize = 0,
pub fn row(self: *const PickerBody, i: usize) []const u8 {
return self.text[i][0..self.lens[i]];
}
};
/// What the state column says: the POLLER's last answer, never the wall's
/// tiles. A host with no tiles is exactly the one the user is about to
/// birth on, and it has to say whether the daemon is answering at all.
pub fn hostState(buf: []u8, h: *Host) []const u8 {
// `applied` is set by the first list to reach the wall, well or badly,
// so a host that has never reported is `connecting` — not `no
// sessions`, which would send the user to birth on a dead machine.
if (!h.applied) return "connecting";
if (!h.poll.reachable.load(.acquire)) {
// ssh's own sentence when there is one — `No route to host`,
// `Permission denied (publickey)` — because `unreachable` alone
// tells the user nothing they can act on. Cut to `buf`, which is
// the row's, so a long line shortens rather than overflowing.
var said_buf: [handoff.reason_max]u8 = undefined;
const said = h.poll.reasonSnapshot(&said_buf);
const head = "unreachable: ";
// Written by hand rather than by `bufPrint`, whose failure leaves
// `buf` UNWRITTEN: a caller with a narrow buffer would then paint
// whatever the stack held. Truncating is the whole contract here.
if (said.len == 0 or buf.len <= head.len) return "unreachable";
const n = @min(said.len, buf.len - head.len);
@memcpy(buf[0..head.len], head);
@memcpy(buf[head.len..][0..n], said[0..n]);
return buf[0 .. head.len + n];
}
var n: usize = 0;
h.poll.list_mu.lock();
// The same walk `planHostDiff` grades panes through, so the count a row
// advertises is the number of sessions Enter can open on that host.
var it = proto.sessionsIter(h.poll.list[0..h.poll.list_len]);
while (it.next()) |_| n += 1;
h.poll.list_mu.unlock();
var count_buf: [16]u8 = undefined;
const count: []const u8 = switch (n) {
0 => "no sessions",
1 => "1 session",
else => std.fmt.bufPrint(&count_buf, "{d} sessions", .{n}) catch "sessions",
};
if (h.drift_len == 0) {
// Verbatim only if it fits: `count` may live in this frame's
// buffer, and the caller keeps a slice of ITS buffer, not ours.
if (count.len > buf.len) return "sessions";
@memcpy(buf[0..count.len], count);
return buf[0..count.len];
}
// The drift word beside the count, hand-truncated like the reason
// above and for the same reason: shortening is the contract, and
// `bufPrint`'s failure would leave `buf` holding the stack's garbage.
var w: usize = 0;
for ([_][]const u8{ count, ", ", h.drift[0..h.drift_len] }) |part| {
const room = @min(part.len, buf.len - w);
@memcpy(buf[w..][0..room], part[0..room]);
w += room;
if (w == buf.len) break;
}
return buf[0..w];
}
/// The row's fixed left column: ` N` or ` NN`, plus the marker.
fn rowHeadLen(wide: bool) usize {
return 1 + @as(usize, if (wide) 2 else 1) + 2;
}
/// One row: ` N> SPELLING STATE `, padded to `cols` so the box has an
/// edge. The spelling is cut from the LEFT, because the head of a socket
/// path is what every daemon on one machine has in common.
pub fn pickerRow(out: []u8, n: usize, wide: bool, spelling: []const u8, state: []const u8, selected: bool, cols: u16) []const u8 {
// The same two-wide marker a label bar wears, so the eye reads the
// popup and the wall the same way and rows do not shift as it moves.
// `wide` is the number's half of that: an unpadded 10 puts its spelling
// a column right of row 9's.
const marker: []const u8 = if (selected) "> " else " ";
var head_buf: [16]u8 = undefined;
const head = (if (wide)
std.fmt.bufPrint(&head_buf, " {d: >2}{s}", .{ n, marker })
else
std.fmt.bufPrint(&head_buf, " {d}{s}", .{ n, marker })) catch return out[0..0];
// The two spellings of one width, tied together: `pickerRows` budgets
// the state against `rowHeadLen`, and a head that grew here without it
// would hand the state columns this row does not have.
std.debug.assert(head.len == rowHeadLen(wide));
const room = @min(@as(usize, cols), out.len);
const field = room -| head.len -| state.len -| 2;
const cut = if (spelling.len > field) spelling[spelling.len - field ..] else spelling;
var w: usize = 0;
for ([_][]const u8{ head, cut }) |part| {
const take = @min(part.len, room - w);
@memcpy(out[w..][0..take], part[0..take]);
w += take;
}
while (w < room -| state.len -| 1 and w < room) : (w += 1) out[w] = ' ';
for ([_][]const u8{ state, " " }) |part| {
const take = @min(part.len, room - w);
@memcpy(out[w..][0..take], part[0..take]);
w += take;
}
return out[0..w];
}
/// Whether the popup on screen is one the WALL opened rather than the user,
/// and what the next pass through the keyboard loop owes it.
pub const PickerAuto = struct {
/// The popup currently on screen is the WALL's, not the user's.
on: bool = false,
/// This spell of emptiness has already had its one auto-open. Distinct
/// from `on`, and that is the whole of it: the Esc that closes the
/// wall's popup leaves the wall STILL EMPTY, and a single flag would
/// reopen it over the one-line text the Esc asked for.
spent: bool = false,
pub const Step = enum { open, close, leave };
/// What an empty (or newly un-empty) wall owes the popup.
pub fn step(self: *PickerAuto, is_tty: bool, empty: bool, picking: bool, prompting: bool) Step {
if (empty) {
if (is_tty and !picking and !self.spent) {
self.spent = true;
self.on = true;
return .open;
}
// A popup already up on an empty wall has had this emptiness's
// one open, whoever opened it. Left unspent, the Esc that closes
// a picker the USER opened is answered by the wall opening it
// straight back, and the one-line text costs two Escs.
if (is_tty and picking) self.spent = true;
return .leave;
}
// The wall has tiles again, so the next emptiness earns its own open.
self.spent = false;
// A tile arriving takes the screen back from a popup nobody asked
// for — but never out from under a spelling in progress, because
// `prompting` layers over `picking` and clearing the lower one
// alone leaves an editor eating every key with nothing on screen.
if (self.on and !prompting) {
self.on = false;
return .close;
}
return .leave;
}
/// The user closed it by hand.
pub fn taken(self: *PickerAuto) void {
// The next tile to arrive must not force-close a popup they then
// open themselves. `spent` is untouched: an Esc on an empty wall
// asked for the one-line text, not for the popup again.
self.on = false;
}
};
/// How many rows of ssh's question the box will show. Past it the question
/// is cut: a prompt that filled the terminal would leave nowhere to type.
pub const ask_rows_max: usize = 4;
/// The prompt box's rendered rows: the question, wrapped, and the answer
/// line under it. Rendered once and read twice — by the writer and by the
/// tests — so the box on the screen is the box the claims are about.
pub const AskBody = struct {
text: [ask_rows_max + 1][picker_row_max]u8 = undefined,
lens: [ask_rows_max + 1]usize = [_]usize{0} ** (ask_rows_max + 1),
n: usize = 0,
pub fn row(self: *const AskBody, i: usize) []const u8 {
return self.text[i][0..self.lens[i]];
}
};
/// Pure, so the whole box — the wrap, the cut, the stars — is assertable
/// with no terminal anywhere near it.
pub fn askRows(body: *AskBody, prompt: []const u8, answer: []const u8, kind: askpass.Kind, cols: u16) void {
const w = @min(@as(usize, cols), picker_row_max);
// One space of margin each side, the picker's own inset.
const room = w -| 2;
body.n = 0;
var rest = prompt;
while (body.n < ask_rows_max and rest.len > 0 and room > 0) {
const take = @min(rest.len, room);
body.text[body.n][0] = ' ';
@memcpy(body.text[body.n][1..][0..take], rest[0..take]);
body.lens[body.n] = take + 1;
body.n += 1;
rest = rest[take..];
}
// A notice is not a question — "Confirm user presence for key ..." —
// and ssh takes no answer for it: the box goes away when the touch
// lands and the helper is killed. An input line under it would invite
// an answer nothing would ever read.
if (kind == .notice) return;
// The cursor is drawn, not placed: the terminal's own caret is hidden
// for as long as a popup owns the screen, and a box with no visible
// insertion point reads as one that is not listening.
var line: [picker_row_max]u8 = undefined;
var n: usize = 0;
for ("> ") |c| {
line[n] = c;
n += 1;
}
// Stars per BYTE, and the count is deliberate: it is the only feedback
// a typist gets that the box took the key, and ssh is not going to
// echo it back.
const shown = @min(answer.len, w -| 4);
const mask = kind == .secret;
for (answer[answer.len - shown ..]) |c| {
line[n] = if (mask) '*' else c;
n += 1;
}
line[n] = '_';
n += 1;
const at = @min(body.n, ask_rows_max);
@memcpy(body.text[at][0..n], line[0..n]);
body.lens[at] = n;
body.n = at + 1;
}
/// ssh's question, over everything: the picker included, because a prompt
/// ARRIVES and the box that owns the keyboard is the one that came last.
pub fn paintAsk(shared: *Shared, prompt: []const u8, prefix: *const interact.PrefixFilter) void {
if (!shared.is_tty) return;
// Set before the lock and read under it, `paintPicker`'s ordering.
shared.ask_open.store(true, .release);
shared.paint_mu.lock();
defer shared.paint_mu.unlock();
const cols = shared.size.cols;
const rows = shared.size.rows;
const w: u16 = @min(cols, @as(u16, picker_row_max));
const left: u16 = (cols -| w) / 2;
var body: AskBody = .{};
askRows(&body, prompt, prefix.askLine(), prefix.kind, w);
const cramped = cols < picker_min_cols or rows < picker_min_rows;
const want: u16 = @intCast(@min(@as(usize, rows), body.n + 1));
const height: u16 = if (cramped) 1 else want;
const top: u16 = (rows -| height) / 2;
var out: [picker_frame_max]u8 = undefined;
var fbs = std.io.fixedBufferStream(&out);
const wr = fbs.writer();
wr.writeAll("\x1b[?25l") catch return;
// No host on the header: this client cannot say which tile's ssh is
// asking without reading a pump's own transport, and ssh's prompts
// name the box themselves (`user@host's password:`).
pickerLine(wr, top, left, w, " ssh");
if (!cramped) {
var r: u16 = 0;
while (r < body.n and top + 1 + r < rows) : (r += 1)
pickerLine(wr, top + 1 + r, left, w, body.row(r));
}
// The picker's screen is gone under this box, so its next paint owes a
// frame however unchanged its rows are — and must not ECH over rows
// this box now holds, which is why the geometry goes with the hash.
shared.picker_frame = .{};
proto.writeAllFd(shared.out_fd, fbs.getWritten()) catch {};
}
/// Whether this action is the prompt box's.
pub fn isAskAction(a: interact.PrefixFilter.Action) bool {
return switch (a) {
.ask_answer, .ask_decline => true,
else => false,
};
}
/// Whether this action is the picker's. The popup answers every one of
/// them, so the arms below it in `run` name them only to stay exhaustive.
pub fn isPickAction(a: interact.PrefixFilter.Action) bool {
return switch (a) {
.pick_open,
.pick_move,
.pick_select,
.pick_birth,
.pick_forget,
.pick_add_open,
.pick_close,
.pick_enter,
.pick_back,
.pick_end,
=> true,
else => false,
};
}
/// How many sessions a host's last answer named — the session level's row
/// count, and what a row step and a digit are clamped against. Takes the
/// table and an index rather than a host: `picker_sel` can name a slot the
/// table does not have (an empty hosts file leaves it at 0), and a level
/// that indexed that would fault on a keystroke.
pub fn sessionCount(host_table: []Host, host: usize) usize {
if (host >= host_table.len) return 0;
var list_buf: [proto.sessions_reply_max]u8 = undefined;
var it = proto.sessionsIter(host_table[host].poll.snapshot(&list_buf));
var n: usize = 0;
while (it.next()) |_| n += 1;
return n;
}
/// The session row `d` away. WRAPPED, unlike `pickerStep`: a session list is
/// short and every row on it is a session that already exists, so rolling
/// round costs nothing — the key that acts is Enter, not the move.
pub fn rowStep(row: usize, d: i8, n: usize) usize {
if (n == 0) return 0;
if (d < 0) return if (row == 0) n - 1 else row - 1;
return if (row + 1 >= n) 0 else row + 1;
}
/// The tile showing `name` on `host`, or null. Per HOST, because two daemons
/// may each have a session called `work` and a lookup by name alone would
/// mark the wrong one.
fn paneOf(w: Wall, host: usize, name: []const u8) ?usize {
for (w.liveTiles(), w.livePresent(), 0..) |*t, p, i| {
if (p and wall_host.ownedBy(t, host) and
std.mem.eql(u8, proto.resolveName(t.r.session), name)) return i;
}
return null;
}
/// The name on a session row of a list already in hand, copied out: the
/// list is a snapshot on some caller's stack and every caller outlives it.
fn nameAt(list: []const u8, row: usize, out: *[proto.session_name_max]u8) ?[]const u8 {
var it = proto.sessionsIter(list);
var i: usize = 0;
while (it.next()) |name| : (i += 1) {
if (i != row) continue;
// `sessionsIter` yields only valid names, which are bounded by
// `session_name_max` — the guard is here so a peer that got past
// the iterator cannot smash this buffer.
if (name.len > out.len) return null;
@memcpy(out[0..name.len], name);
return out[0..name.len];
}
return null;
}
fn sessionAt(h: *Host, row: usize, out: *[proto.session_name_max]u8) ?[]const u8 {
var list_buf: [proto.sessions_reply_max]u8 = undefined;
return nameAt(h.poll.snapshot(&list_buf), row, out);
}
/// The second level: one row per session the host's last answer named.
/// "on this wall" when the layout already has it, and the holder count when
/// the daemon is new enough to say (`proto.parseSessionsHolds`); an old
/// daemon's rows carry no count rather than a zero that would read as "safe
/// to end". The count is EVERY holder, this client included — what the end
/// key gets is the DAEMON's verdict on who else is there, not this number.
pub fn sessionRows(body: *PickerBody, w: Wall, host: usize, sel_row: usize, cols: u16) void {
body.n = 0;
if (host >= w.hosts.len) return;
const h = &w.hosts[host];
var list_buf: [proto.sessions_reply_max]u8 = undefined;
const list = h.poll.snapshot(&list_buf);
// Counted before anything is spelled, `pickerRows`' reason: the padding
// is the LIST's, and a row cannot know from its own number whether a 10
// is coming.
var count: usize = 0;
var counter = proto.sessionsIter(list);
while (counter.next()) |_| count += 1;
const wide = count >= 10;
var it = proto.sessionsIter(list);
var row: usize = 0;
while (it.next()) |name| : (row += 1) {
if (body.n >= wv.max_tiles) break;
var state_buf: [48]u8 = undefined;
var state: []const u8 = "";
const on_wall = paneOf(w, host, name) != null;
if (proto.parseSessionsHolds(list, name)) |n| {
state = std.fmt.bufPrint(&state_buf, "{s}{d} client{s}", .{
if (on_wall) "on this wall, " else "",
n,
if (n == 1) "" else "s",
}) catch "";
} else if (on_wall) state = "on this wall";
body.host[body.n] = host;
body.lens[body.n] = pickerRow(&body.text[body.n], body.n + 1, wide, name, state, row == sel_row, cols).len;
body.n += 1;
}
}
/// Enter on a session row: a pane for it, joined (never created), zoomed to.
/// A session already on the wall is only zoomed to. The layout is written,
/// because this is one of the three places a pane comes from.
pub fn pickAdd(w: Wall, host: usize, row: usize) ?usize {
// A forgotten host's poller has exited, so a pane born on one could
// never be confirmed or vanished — `pickBirth`'s reason, and the same
// here because a joined pane is graded by that poller too.
if (host >= w.hosts.len or w.hosts[host].forgotten.load(.acquire)) {
wv.setNotice(w.shared, "[no host to add a session from - a adds one]");
return null;
}
const h = &w.hosts[host];
var name_buf: [proto.session_name_max]u8 = undefined;
const name = sessionAt(h, row, &name_buf) orelse {
// The list moved under the popup: a poll landed between the paint
// and the key, and the row the user chose is not there any more.
wv.setNotice(w.shared, "[no session on that row]");
return null;
};
if (paneOf(w, host, name)) |at| {
// Already on the wall: the answer is the pane they meant, not a
// second tile onto one session.
wv.setFocus(w.liveTiles(), w.shared, at);
return at;
}
// Enter IS the ask, `pickBirth`'s reason: the row may be the one the
// poller calls unreachable, and choosing it means starting that daemon.
var target = h.spec.target;
if (target == .hand) target.hand.asked = true;
const anchor = wall_layout.anchorTile(w.livePresent(), w.shared.sel);
const has_anchor = wv.presentCount(w.livePresent()) > 0;
const at = wv.birthTile(w, .{
// `name` is this stack's buffer until the wall takes the tile —
// see `Birth.borrowed`.
// No `-A`: the popup can cross to a host the user never offered an
// agent, and joining a session is not the place to hand one over.
.r = .{ .target = target, .label = "", .session = name, .agent = false },
.from = anchor,
.place = .beside_focus,
// JOINED: the daemon already has this session, and a create would
// ask for a name it has.
.creates = false,
.born_from = if (has_anchor) anchor else null,
// A refusal is one pane's, not the wall's: the popup put this here,
// and an empty wall must survive the daemon saying no.
.keeps_wall = true,
.host = host,
.borrowed = true,
}) orelse {
wv.setNotice(w.shared, "[no room on the wall for another pane]");
return null;
};
wv.spawnPump(&w.tiles[at]);
wv.setFocus(w.liveTiles(), w.shared, at);
wall_layout.persist(w);
return at;
}
/// `x` on a session row: the daemon's two-step, from a side connection of
/// its own — the session may have no pane on this wall, so there is no pump
/// to ask through. The first press on a session others hold is refused with
/// the count and arms 3 s; a second press inside that window forces. Every
/// other refusal arms NOTHING: only "others attached" is a question the
/// user can answer by pressing again.
pub fn pickEnd(w: Wall, host: usize, row: usize, now: i64) void {
if (host >= w.hosts.len) return;
const h = &w.hosts[host];
var list_buf: [proto.sessions_reply_max]u8 = undefined;
const list = h.poll.snapshot(&list_buf);
var name_buf: [proto.session_name_max]u8 = undefined;
const name = nameAt(list, row, &name_buf) orelse {
wv.setNotice(w.shared, "[no session on that row]");
return;
};
const armed = w.shared.pick_end.armedFor(host, name, now);
// The POLLER's recipe, never the row's own target: an end must not
// start a daemon and must not reach for a terminal. `poll_target`
// carries ssh's `BatchMode=yes` and a connect timeout and has `asked`
// false, so a dark host fails in seconds instead of parking the
// keyboard thread in the TCP retry schedule with a password prompt
// going to /dev/tty under the popup.
const out = client.endSession(w.alloc, h.spec.poll_target, name, armed) catch |e| {
// A daemon with no `end_req` arm answers an unknown frame with
// silence, so the budget running out IS the answer: the box is too
// old, in the sentence the pump's own chord uses. Asked on the wire
// rather than guessed from the absence of a `# holds` line — the
// released v0.0.1-16 daemon answers `end_req` and sends no holds
// line, and refusing it up front refused a daemon that works.
if (e == error.Timeout) {
wv.setNotice(w.shared, "[daemon too old to end a session]");
return;
}
var buf: [96]u8 = undefined;
wv.setNotice(w.shared, std.fmt.bufPrint(
&buf,
"[could not ask {s} to end {s}: {s}]",
.{ h.spec.spelling, name, @errorName(e) },
) catch "[could not ask the daemon]");
return;
};
var buf: [96]u8 = undefined;
if (out.accepted) {
// An accepted end DISARMS: the window must not outlive its session,
// and the row's number is about to belong to something else.
w.shared.pick_end.clear();
wv.setNotice(w.shared, std.fmt.bufPrint(
&buf,
"[ending {s} on {s}]",
.{ name, h.spec.spelling },
) catch "[ending the session]");
} else if (out.others > 0) {
w.shared.pick_end.arm(host, name, now + wv.end_arm_ms);
wv.setNotice(w.shared, std.fmt.bufPrint(&buf, "[{s}: {d} other{s} attached - x again to end]", .{
name, out.others, if (out.others == 1) "" else "s",
}) catch "[others attached - x again to end]");
} else {
// Not a two-step: the daemon has not got that session, or could not
// read the frame. Arming here would leave the next `x` forcing an
// end nobody said was blocked. Said in THIS client's words —
// `parseEndReply` hands back the peer's bytes unfiltered.
w.shared.pick_end.clear();
wv.setNotice(w.shared, std.fmt.bufPrint(
&buf,
"[{s}: {s}]",
.{ name, wall_pump.endRefusalWord(out.reason()) },
) catch "[the daemon refused to end that session]");
}
// The row must leave the list without waiting out a poll: the end is
// the daemon's to carry out, and this is what asks it when.
h.poll.poke.store(true, .release);
}
/// Enter or `c`: a new session on the SELECTED host, tile or no tile.
/// Null when nothing was made.
pub fn pickBirth(w: Wall, sel: usize) ?usize {
// The tile creates on attach exactly as a chord-born one does: no side
// connection, no second road onto the wall. A FORGOTTEN host's poller has
// exited, so a tile born on one could never be confirmed or vanished — it
// would sit there naming a machine the user just removed.
if (sel >= w.hosts.len or w.hosts[sel].forgotten.load(.acquire)) {
// The one key the footer advertises, on a wall with nothing to
// birth on: an Enter that closes the popup and does nothing reads
// as a broken key rather than as an empty hosts file.
wv.setNotice(w.shared, "[no hosts to start a session on - a adds one]");
return null;
}
const h = &w.hosts[sel];
// Enter IS the ask, and this COPY is where that is written down: the row is
// often the one the poller calls unreachable, and starting that daemon is
// what choosing it means. The spec is untouched, since the poller re-dials
// off it every second and must not resurrect a stopped daemon.
var target = h.spec.target;
if (target == .hand) target.hand.asked = true;
var list_buf: [proto.sessions_reply_max]u8 = undefined;
const list = h.poll.snapshot(&list_buf);
// The daemon's own naming, off the daemon's own list: the name the
// `c` chord would have landed on, reached without a pump to ask.
var name_buf: [proto.session_name_max]u8 = undefined;
const name = client.nextFreeName(&name_buf, list);
// The name is free on the DAEMON, which is not the same as free on this
// WALL. A daemon that restarted answers an empty list while the panes
// it used to serve stand there wearing `gone`, so the next free name is
// `0` and this would put a second `box#0` beside the first — two panes
// spelling one leaf, which `seedLayout` refuses as a repeat and which
// therefore costs the user the whole file at the next start. The pane
// that already spells it is the answer, and Enter there is the key that
// re-creates the session in its own rect.
if (paneOf(w, sel, name)) |at| {
wv.setFocus(w.liveTiles(), w.shared, at);
var buf: [96]u8 = undefined;
wv.setNotice(w.shared, std.fmt.bufPrint(
&buf,
"[{s} is already a pane - Enter there re-creates it]",
.{name},
) catch "[that session is already a pane]");
return at;
}
const anchor = wall_layout.anchorTile(w.livePresent(), w.shared.sel);
const has_anchor = wv.presentCount(w.livePresent()) > 0;
// `-A` is inherited only within one host. A chord inherits it because
// the new session is on the machine the offer was already made to; the
// picker can cross to a host the user never offered an agent, and a
// popup must not be the thing that hands a stranger the keys.
const agent = has_anchor and w.tiles[anchor].host != null and
w.tiles[anchor].host.? == sel and w.tiles[anchor].r.agent;
const at = wv.birthTile(w, .{
// `name` is this stack's buffer until the wall takes the tile —
// see `Birth.borrowed`.
.r = .{ .target = target, .label = "", .session = name, .agent = agent },
.from = anchor,
.place = .beside_focus,
.creates = true,
// Nowhere to hand a refusal back to on an empty wall, so `keeps_wall`
// is what tells `endAction` the wall outlives this tile.
.born_from = if (has_anchor) anchor else null,
.keeps_wall = true,
.host = sel,
.borrowed = true,
}) orelse {
wv.setNotice(w.shared, "[no room on the wall for another session]");
return null;
};
wv.spawnPump(&w.tiles[at]);
// The pane is the wall's the moment it is born, not once its dial
// answers: a birth onto a box that turns out to be dark is still a pane
// the user authored, and the next start owes them it.
wall_layout.persist(w);
// The new session must not wait out a poll to be confirmed by the list
// that will also stop the diff from vanishing it.
h.poll.poke.store(true, .release);
return at;
}
/// `x`: the host leaves the file, its poller stops and its tiles go.
pub fn pickForget(w: Wall, sel: usize, path: ?[]const u8) void {
// The SESSIONS keep running: forgetting a daemon is `mux hosts rm`
// typed from inside, and that ends nothing.
if (sel >= w.hosts.len) return;
const h = &w.hosts[sel];
if (h.forgotten.load(.acquire)) return;
// The file first, for `addHost`'s reason. What the file SAID is what the
// notice says: a `false` is a line that was not there to remove, and the
// tiles go either way — so a flat `[forgot ...]` would leave the wall and
// the file disagreeing with nobody told.
var gone_from_file = true;
var why: ?anyerror = null;
if (path) |p| {
gone_from_file = hosts.forget(w.alloc, p, h.spec.spelling) catch |e| blk: {
why = e;
break :blk false;
};
}
h.forgotten.store(true, .release);
for (0..w.live.*) |i| {
if (w.present[i] and wall_host.ownedBy(&w.tiles[i], sel))
wv.vanishTile(w.liveTiles(), w.livePresent(), w.shared, i, null);
}
// The host left the hosts file above; its panes leave the layout file
// here, in one write for the whole loop. A forget the file did not
// record would put every one of them back on the next start.
wall_layout.persist(w);
var buf: [96]u8 = undefined;
// A run that saves no layout (`Shared.layout_path` null — a pipe, a
// `--via` wall, a file this run refused) has just taken the panes off
// the SCREEN and nothing off the file, so the next start puts every one
// of them back. Said here, where the forget is, rather than left for
// the user to discover on a wall they thought they had changed.
const tail: []const u8 = if (w.shared.layout_path == null) " - layout not updated" else "";
const said = if (why) |e|
std.fmt.bufPrint(&buf, "[hosts file not updated: {s}{s}]", .{ hosts.reason(e), tail }) catch "[hosts file not updated]"
else if (!gone_from_file)
std.fmt.bufPrint(&buf, "[{s} was not on the wall{s}]", .{ h.spec.spelling, tail }) catch "[that host was not on the wall]"
else
std.fmt.bufPrint(&buf, "[forgot {s}{s}]", .{ h.spec.spelling, tail }) catch "[forgot the host]";
wv.setNotice(w.shared, said);
}
/// Which of the popup's two lists is on screen, and where its selection is.
/// The host index is `sel`, which both levels use — the session level lists
/// the sessions of the host `sel` names.
pub const PickerView = struct {
level: interact.PrefixFilter.PickLevel = .hosts,
/// The selected SESSION row. Unread at the host level, where `sel` is
/// the selection.
row: usize = 0,
};
/// The popup, painted by the KEYBOARD thread — the only one that knows the
/// picker exists — on open, on every key, and on every list that lands under
/// it, so the state column is live while the user reads it.
pub fn paintPicker(w: Wall, sel: usize, view: PickerView, line: ?[]const u8) void {
const shared = w.shared;
const host_table = w.hosts;
if (!shared.is_tty) return;
// The flag is set before the lock and READ under it (`tilePaintBegin`),
// which is what orders the two: a pump either takes `paint_mu` first and
// finishes its rect before this paint starts, or takes it after and sees
// the flag already set.
shared.picker_open.store(true, .release);
shared.paint_mu.lock();
defer shared.paint_mu.unlock();
const cols = shared.size.cols;
const rows = shared.size.rows;
const box_w: u16 = @min(cols, @as(u16, picker_row_max));
const left: u16 = (cols -| box_w) / 2;
var body: PickerBody = .{};
// The selected row differs by level: at the host level it is wherever
// the selected HOST landed in the list a forgotten one has left; at the
// session level the row IS the selection.
var sel_row: usize = 0;
var title_buf: [picker_row_max]u8 = undefined;
var title: []const u8 = " hosts";
if (view.level == .sessions) {
sessionRows(&body, w, sel, view.row, box_w);
sel_row = @min(view.row, body.n -| 1);
const spelling = if (sel < host_table.len) host_table[sel].spec.spelling else "";
title = std.fmt.bufPrint(&title_buf, " sessions on {s}", .{spelling}) catch " sessions";
} else {
pickerRows(&body, host_table, sel, box_w);
for (body.host[0..body.n], 0..) |hi, i| {
if (hi == sel) sel_row = i;
}
}
// Too small for a box is not too small for an answer: the header alone
// still says which popup has the keyboard.
const cramped = cols < picker_min_cols or rows < picker_min_rows;
const want: u16 = @intCast(@min(@as(usize, rows), body.n + 2));
const height: u16 = if (cramped) 1 else want;
const top: u16 = (rows -| height) / 2;
// The window onto the rows when the terminal cannot hold them all: the
// selection stays visible, because it is what every other key acts on.
const shown: usize = height -| 2;
var first: usize = 0;
if (shown > 0 and sel_row >= shown) first = sel_row - shown + 1;
// The notice wins the footer over the legend and the editor's line: a
// refusal just earned cannot wait for the next keystroke. PEEKED, because
// the stamp below can still refuse the frame; taken once the bytes are out.
var notice_buf: [96]u8 = undefined;
const notice = peekNoticeLocked(shared, ¬ice_buf);
const foot: []const u8 = if (notice.len > 0)
notice
else if (line) |l|
l
else if (view.level == .sessions)
" Enter add to wall c new session x end Esc back"
else
" Enter sessions c new session x forget a add Esc";
var out: [picker_frame_max]u8 = undefined;
var fbs = std.io.fixedBufferStream(&out);
const wr = fbs.writer();
// The rows of the box that is on the screen NOW which this one does not
// cover, erased before the new box is written. Nothing else would: tiles
// do not paint while the popup is up, so a box that shrank — a host list
// whose Enter descends into a one-session list, an `x` that shortens the
// hosts — used to leave its outer rows standing until the close, and the
// user read four host rows with one session row painted over the middle
// of them.
const prev = shared.picker_frame;
// A box whose columns do not hold every column the old one wrote leaves
// stale cells on a row it otherwise covers. Only a width change does
// that, and a WINCH clears the whole screen through `relayout`, which
// forgets the frame before the next paint runs, so no clear is owed at
// all on that path. The check is here so a future paint that changes the
// box's width without clearing the screen cannot leave a stale column.
const spans = left <= prev.left and left + box_w >= prev.left + prev.cols;
if (prev.stamp != 0) {
var cleared = false;
var pr: u16 = prev.top;
while (pr < prev.top + prev.rows) : (pr += 1) {
if (spans and pr >= top and pr < top + height) continue;
if (!cleared) {
// Default rendition first: ECH erases with the CURRENT one,
// and every row of the box it is erasing wears reverse video.
wr.writeAll("\x1b[0m") catch return;
cleared = true;
}
clearSpan(wr, pr, prev.left, prev.cols);
}
}
// Where the box itself starts. The stamp below is taken over THIS much
// and no more, so the hash stays a function of the box alone: a repaint
// whose rows are unchanged is refused whether or not the write before it
// owed a clear, and a refused frame is one whose geometry matches, which
// is one that owes no clear.
const box_at = fbs.getWritten().len;
// The cursor is hidden for as long as the popup owns the screen: a
// caret left blinking in a tile says the keys are going there.
wr.writeAll("\x1b[?25l") catch return;
pickerLine(wr, top, left, box_w, title);
if (!cramped) {
var r: u16 = 0;
while (r < shown and first + r < body.n) : (r += 1)
pickerLine(wr, top + 1 + r, left, box_w, body.row(first + r));
pickerLine(wr, top + height - 1, left, box_w, foot);
}
const written = fbs.getWritten();
// Nothing changed, nothing written. The pollers report once a second
// per host and every one of them repaints this box; rewriting an
// identical screen at that rate is a terminal that never goes quiet.
const stamp = std.hash.Wyhash.hash(0, written[box_at..]);
if (stamp == shared.picker_frame.stamp) return;
shared.picker_frame = .{
.stamp = stamp,
.top = top,
.left = left,
.rows = height,
.cols = box_w,
};
shared.notice_len = 0;
proto.writeAllFd(shared.out_fd, written) catch {};
}
/// What the popup owes the screen on a pass with no keystroke: whether to
/// paint at all, and the footer to paint with.
const PickerRepaint = struct { due: bool, line: ?[]const u8 };
/// `prompting` is not a term: `relayout` clears the whole screen on any
/// pane a poll vanishes while the box is up, and a paint skipped for the
/// spelling editor leaves the user typing into an editor the screen no
/// longer shows — every key still reaching it.
pub fn pickerRepaint(buf: []u8, prefix: *const interact.PrefixFilter, trigger: bool) PickerRepaint {
if (!prefix.picking) return .{ .due = false, .line = null };
const line: ?[]const u8 = if (prefix.prompting)
(std.fmt.bufPrint(buf, ": {s}_", .{prefix.promptLine()}) catch "")
else
null;
return .{ .due = trigger, .line = line };
}
/// One row of the box: reverse video, padded to the box width. The same
/// chrome a label bar and a rail wear, so the popup never reads as a
/// session's own output.
fn pickerLine(wr: anytype, row: u16, left: u16, w: u16, text: []const u8) void {
wr.print("\x1b[{d};{d}H\x1b[7m", .{ row + 1, left + 1 }) catch return;
const take = @min(text.len, @as(usize, w));
wr.writeAll(text[0..take]) catch return;
var i: usize = take;
while (i < w) : (i += 1) wr.writeByte(' ') catch break;
wr.writeAll("\x1b[0m") catch return;
}
/// One row of a box that is no longer there. Span-bounded ECH over the
/// columns that box held and not one more, the same rule every tile clear
/// obeys: the rest of the row can hold a neighbouring tile's cells or a
/// rail, and a line-wide erase would take them with it.
fn clearSpan(wr: anytype, row: u16, left: u16, w: u16) void {
if (w == 0) return;
wr.print("\x1b[{d};{d}H\x1b[{d}X", .{ row + 1, left + 1, w }) catch return;
}
pub fn peekNoticeLocked(shared: *const Shared, out: []u8) []const u8 {
const n = @min(shared.notice_len, out.len);
@memcpy(out[0..n], shared.notice[0..n]);
return out[0..n];
}
/// The hosts still on the wall, in file order — the picker's rows, as
/// indices into the table a forgotten host never leaves.
fn pickerVisible(host_table: []Host, out: *[wv.max_tiles]usize) []const usize {
var n: usize = 0;
for (host_table, 0..) |*h, hi| {
if (h.forgotten.load(.acquire)) continue;
out[n] = hi;
n += 1;
}
return out[0..n];
}
/// The host `d` rows away. CLAMPED, not wrapped: a held key that rolled
/// the list round would birth on whatever it stopped on.
pub fn pickerStep(host_table: []Host, sel: usize, d: i8) usize {
var buf: [wv.max_tiles]usize = undefined;
const vis = pickerVisible(host_table, &buf);
if (vis.len == 0) return sel;
var row: usize = 0;
for (vis, 0..) |hi, i| {
if (hi == sel) row = i;
}
const moved = @as(isize, @intCast(row)) + d;
const clamped: usize = if (moved < 0) 0 else @min(@as(usize, @intCast(moved)), vis.len - 1);
return vis[clamped];
}
/// The host a picker row number names, or null when the list is shorter.
pub fn pickerAt(host_table: []Host, row: usize) ?usize {
var buf: [wv.max_tiles]usize = undefined;
const vis = pickerVisible(host_table, &buf);
return if (row < vis.len) vis[row] else null;
}
/// Where the selection rests when the picker opens: the wanted host if it
/// is still on the wall, else the first row — never a forgotten slot.
pub fn pickerNearest(host_table: []Host, want: usize) usize {
var buf: [wv.max_tiles]usize = undefined;
const vis = pickerVisible(host_table, &buf);
for (vis) |hi| {
if (hi == want) return want;
}
return if (vis.len > 0) vis[0] else want;
}
/// The picker's body: one row per host still on the wall, in file order.
/// `sel` is a HOST index, not a row — a forgotten host keeps its slot
/// (a poller holds the pointer and `Tile.host` indexes it) and only
/// leaves the list.
pub fn pickerRows(body: *PickerBody, host_table: []Host, sel: usize, cols: u16) void {
body.n = 0;
// Counted before anything is spelled: the padding is the TABLE's, and a
// row cannot know from its own number whether a 10 is coming.
var listed: usize = 0;
for (host_table) |*h| {
if (!h.forgotten.load(.acquire)) listed += 1;
}
const wide = listed >= 10;
for (host_table, 0..) |*h, hi| {
if (h.forgotten.load(.acquire)) continue;
// Wide enough for an `unreachable` carrying a whole `handoff.Reason`.
// The state gets what the row can SPARE, never all it could fill:
// `pickerRow` cuts the spelling and never the state, so an unbudgeted
// 71-byte reason leaves three columns of the host name at 80 columns.
// The floor is the bare word, which is what a narrow row always had.
var state_buf: ["unreachable: ".len + handoff.reason_max]u8 = undefined;
const spare = @as(usize, cols) -| rowHeadLen(wide) -| h.spec.spelling.len -| 2;
const room = @max("unreachable".len, @min(spare, state_buf.len));
const state = hostState(state_buf[0..room], h);
const at = body.n;
body.host[at] = hi;
body.lens[at] = pickerRow(&body.text[at], at + 1, wide, h.spec.spelling, state, hi == sel, cols).len;
body.n += 1;
}
}