diff --git a/src/ui.zig b/src/ui.zig index 15dcf12..b2c19a9 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -1599,14 +1599,23 @@ const Ui = struct { } else if (self.autoFocusable()) |i| { self.selected = i; self.attachSelected(); - } else if (self.view) |v| { - // No automatic candidate. A live view keeps running, but a - // dead one makes room for the empty state. - if (v.state != .live) { - v.destroy(); - self.view = null; - if (self.view_name) |n| self.alloc.free(n); - self.view_name = null; + } else { + // No automatic candidate: every other session is held by + // some client or hosts this UI. A dead view makes room for + // the empty state, and selecting (without attaching) the + // most recent session keeps a focus target around, the + // same fallback startup uses. + if (self.view) |v| { + if (v.state != .live) { + v.destroy(); + self.view = null; + if (self.view_name) |n| self.alloc.free(n); + self.view_name = null; + } + } + if (self.view == null and !self.browsing) { + self.selectInitial(); + self.scrollSelectedIntoView(); } } self.clampScroll(); @@ -1652,9 +1661,10 @@ const Ui = struct { return best; } - /// Startup fallback when every session is attached elsewhere: - /// select the most recently active one without attaching, so the - /// sidebar has a focus target but nothing is stolen. + /// Fallback when every session is attached elsewhere: select the + /// most recently active one without attaching, so the sidebar has + /// a focus target but nothing is stolen. Used at startup and when + /// the focused session goes away. fn selectInitial(self: *Ui) void { var best: ?usize = null; for (self.sessions.items, 0..) |entry, i| { @@ -2405,13 +2415,16 @@ const Ui = struct { try out.appendSlice(alloc, "\x1b[K"); const v = self.view orelse { - if (self.sessions.items.len == 0) { - try self.composeNoSessions(y, out); - } else if (self.selected != null and self.sessions.items[self.selected.?].attached) { - try self.composeEmptyRow(y, "attached elsewhere", "click the session to take it over", out); - } else { - try self.composeEmptyRow(y, "no session focused", "pick a session on the left", out); + if (self.selected) |i| { + if (self.sessions.items[i].attached) { + try self.composeEmptyRow(y, "attached elsewhere", "click the session to take it over", out); + return; + } } + // Nothing is focusable (no sessions, or only this UI's + // host): the splash, rather than a placard with no + // actionable advice. + try self.composeNoSessions(y, out); return; }; @@ -2469,7 +2482,7 @@ const Ui = struct { try out.appendSlice(self.alloc, sgr_reset); } - /// The boo wordmark and its ghost, shown when no sessions exist. + /// The boo wordmark and its ghost, shown when nothing is focused. const ghost_art = [_][]const u8{ " _ .-.", "| |__ ___ ___ (o o)", @@ -2478,8 +2491,9 @@ const Ui = struct { "|_.__/ \\___/ \\___/ `~~~'", }; - /// Empty state for a boo with no sessions at all: the wordmark - /// art centered as a block, then a hint underneath. + /// Empty state when nothing is focusable: no sessions at all, or + /// only ones this UI must not attach on its own. The wordmark art + /// centered as a block, then a hint underneath. fn composeNoSessions(self: *Ui, y: u16, out: *std.ArrayList(u8)) !void { const alloc = self.alloc; const l = self.layout; @@ -2813,6 +2827,35 @@ test "ui: automatic focus skips attached sessions and prefers recent ones" { try std.testing.expectEqual(@as(?usize, null), ui.autoFocusable()); } +test "ui: an empty viewport shows the splash, not a placard" { + const alloc = std.testing.allocator; + var ui: Ui = .{ .alloc = alloc, .dir = "", .tty = -1 }; + defer ui.sessions.deinit(alloc); + ui.layout = .init(24, 100); + + var host = "host".*; + var no_title: [0]u8 = .{}; + try ui.sessions.append(alloc, .{ .name = &host, .attached = true, .idle_ms = 0, .title = &no_title }); + + // Nothing selected (say, only this UI's host remains): the ghost + // splash renders instead of a "no session focused" placard. + var out: std.ArrayList(u8) = .empty; + defer out.deinit(alloc); + for (0..ui.layout.viewportRows()) |y| { + try ui.composeViewportCell(@intCast(y), &out); + } + try std.testing.expect(std.mem.indexOf(u8, out.items, "(o o)") != null); + try std.testing.expect(std.mem.indexOf(u8, out.items, "no session focused") == null); + + // A selected session held by another client keeps its hint. + ui.selected = 0; + out.clearRetainingCapacity(); + for (0..ui.layout.viewportRows()) |y| { + try ui.composeViewportCell(@intCast(y), &out); + } + try std.testing.expect(std.mem.indexOf(u8, out.items, "attached elsewhere") != null); +} + test "ui: search matches prefer name prefixes over substrings" { const alloc = std.testing.allocator; var ui: Ui = .{ .alloc = alloc, .dir = "", .tty = -1 }; diff --git a/test/integration.zig b/test/integration.zig index 9c24a97..4539a92 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -1361,6 +1361,84 @@ test "ui: clicking the kill target asks for confirmation" { try waitUiSessionCount(&h, 0); } +test "ui: killing the focused session moves focus to the next one" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("doomed", &.{"cat"}); + try h.startDetached("stay", &.{"cat"}); + try h.sendLine("stay", "STAY-MARK"); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("stay"); + + // Focus "doomed" (first alphabetically, sidebar row 3) so its + // death has somewhere to fall back to. + try ui.send("\x1b[<0;5;3M\x1b[<0;5;3m"); + try h.sendLine("doomed", "DOOM-MARK"); + try ui.waitFor("DOOM-MARK"); + + // Killing the focused session attaches the remaining free one: + // its screen contents render without any further input. + try ui.send("\x01k"); + try ui.waitFor("kill doomed? y/n"); + try ui.send("y"); + try ui.waitFor("STAY-MARK"); + try std.testing.expect(std.mem.indexOf(u8, ui.output.items, "no session focused") == null); +} + +test "ui: killing the last free session points at a held one" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("held", &.{"cat"}); + var holder = try PtyClient.spawn(&h, &.{ "attach", "held" }, 24, 80); + defer holder.deinit(); + try h.sendLine("held", "HELD-MARK"); + try holder.waitFor("HELD-MARK"); + + try h.startDetached("doomed", &.{"cat"}); + + // The UI auto-focuses "doomed", the only free session. + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try h.sendLine("doomed", "DOOM-MARK"); + try ui.waitFor("DOOM-MARK"); + + // After the kill nothing is free to attach: the held session is + // selected without stealing it, with the viewport explaining how + // to take it over. + try ui.send("\x01k"); + try ui.waitFor("kill doomed? y/n"); + try ui.send("y"); + try ui.waitFor("attached elsewhere"); + try ui.waitFor("click the session to take it over"); + try std.testing.expect(std.mem.indexOf(u8, ui.output.items, "no session focused") == null); +} + +test "ui: killing the only session brings back the splash" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("solo", &.{"cat"}); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try h.sendLine("solo", "SOLO-MARK"); + try ui.waitFor("SOLO-MARK"); + + try ui.send("\x01k"); + try ui.waitFor("kill solo? y/n"); + try ui.send("y"); + try ui.waitFor("(o o)"); + try ui.waitFor("no sessions"); + try std.testing.expect(std.mem.indexOf(u8, ui.output.items, "no session focused") == null); +} + test "ui: quit with C-a d leaves sessions running and restores the terminal" { const alloc = std.testing.allocator; var h = try Harness.init(alloc);