Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 63 additions & 20 deletions src/ui.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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;
};

Expand Down Expand Up @@ -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)",
Expand All @@ -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;
Expand Down Expand Up @@ -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 };
Expand Down
78 changes: 78 additions & 0 deletions test/integration.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading