a73x

c89f85dd

feat: open native panes on the focused target

a73x   2026-09-07 13:34

Commit message
feat: open native panes on the focused target

Create below/beside panes in the background using the focused connection and fresh numeric session names. Keep prefix Enter for explicit host and session selection.

Share setup, polling, cancellation, and completion between manual and quick opening, preserving destination checks and uncertain creation outcomes.

Validated with make ci, make native native-e2e, and the existing delayed-picker acceptance checks.

README.md
Old New
@@ -149,8 +149,14 @@ Open the saved workspace, or an explicit target in a temporary workspace:
149 ``` 149 ```
150 150
151 Press `Ctrl+\`, then **v** for a pane below or **b** for a pane beside it. 151 Press `Ctrl+\`, then **v** for a pane below or **b** for a pane beside it.
152 This shows the intended split. Press the prefix again, then **Enter**, to open 152 The new pane uses the focused pane's connection and gets the next free numeric
153 the host picker. Arrows or **j/k** select a row; Enter chooses it. Select a host, 153 session name automatically. It opens in the background; you can keep typing or
154 change focus while it connects. Esc cancels a pending opening. With no focused
155 pane, these bindings open the picker.
156
157 Press the prefix, then **Enter**, to choose a different host or an existing or
158 named session. Arrows or **j/k** select a row; Enter chooses it. **v/b** change
159 the split direction in the host and session lists. Select a host,
154 then an existing session or **New session...**, which asks for a name. **Add host...** 160 then an existing session or **New session...**, which asks for a name. **Add host...**
155 accepts an SSH host, `quic://HOST[:PORT]`, or `--sock PATH` and saves it to the shared 161 accepts an SSH host, `quic://HOST[:PORT]`, or `--sock PATH` and saves it to the shared
156 host catalogue. You can also click picker rows. The split stays on the pane where 162 host catalogue. You can also click picker rows. The split stays on the pane where
@@ -159,7 +165,8 @@ armed direction defaults to side by side.
159 165
160 Esc in a name editor returns without submitting; Esc in sessions returns to hosts; 166 Esc in a name editor returns without submitting; Esc in sessions returns to hosts;
161 Esc in hosts closes the picker while preserving the pending split. Cancelling 167 Esc in hosts closes the picker while preserving the pending split. Cancelling
162 before submitting a new session creates nothing. A name collision is refused. 168 before submitting a new session creates nothing. An explicit name collision is
169 refused; automatic naming refreshes the list and retries a confirmed collision.
163 If creation was submitted but its reply was lost, the picker reports an unknown 170 If creation was submitted but its reply was lost, the picker reports an unknown
164 outcome; refresh the session list before retrying. Existing-session selection 171 outcome; refresh the session list before retrying. Existing-session selection
165 never recreates a vanished shell. Older daemons can serve existing sessions but 172 never recreates a vanished shell. Older daemons can serve existing sessions but
src/client/discovery.zig
Old New
@@ -28,6 +28,10 @@ pub const Options = struct {
28 ticket: Ticket, 28 ticket: Ticket,
29 target: client.Target, 29 target: client.Target,
30 operation: Operation = .list, 30 operation: Operation = .list,
31 /// Background catalogues use a noninteractive SSH recipe. An explicit
32 /// opening on an existing target must retain that target's custom argv.
33 /// Listings remain read-only in either case: inherited startup is cleared.
34 use_poll_recipe: bool = true,
31 timeout_ms: u32 = 15000, 35 timeout_ms: u32 = 15000,
32 wake_ctx: ?*anyopaque = null, 36 wake_ctx: ?*anyopaque = null,
33 wake: ?*const fn (?*anyopaque, Ticket) void = null, 37 wake: ?*const fn (?*anyopaque, Ticket) void = null,
@@ -46,8 +50,12 @@ pub const Job = struct {
46 var arena = std.heap.ArenaAllocator.init(alloc); 50 var arena = std.heap.ArenaAllocator.init(alloc);
47 errdefer arena.deinit(); 51 errdefer arena.deinit();
48 var opts = options; 52 var opts = options;
49 const source = if (opts.operation == .list) try client.pollTargetFor(arena.allocator(), opts.target) else opts.target; 53 const source = if (opts.operation == .list and opts.use_poll_recipe) try client.pollTargetFor(arena.allocator(), opts.target) else opts.target;
50 opts.target = try cloneTarget(arena.allocator(), source); 54 opts.target = try cloneTarget(arena.allocator(), source);
55 if (opts.operation == .list and opts.target == .hand) {
56 opts.target.hand.asked = false;
57 opts.target.hand.narrate = false;
58 }
51 if (opts.operation == .create) { 59 if (opts.operation == .create) {
52 const req = &opts.operation.create; 60 const req = &opts.operation.create;
53 if (!proto.validSessionName(req.name) or req.cols < 2 or req.rows == 0 or req.cols > proto.max_cols) return error.InvalidCreate; 61 if (!proto.validSessionName(req.name) or req.cols < 2 or req.rows == 0 or req.cols > proto.max_cols) return error.InvalidCreate;
@@ -293,3 +301,22 @@ test "picker create uses explicit operation and reports ambiguous cancellation a
293 try std.testing.expectEqual(cancelled, result.may_have_created); 301 try std.testing.expectEqual(cancelled, result.may_have_created);
294 } 302 }
295 } 303 }
304
305 test "same-target listing retains custom SSH argv without inheriting daemon startup" {
306 const job = try Job.start(std.testing.allocator, .{
307 .ticket = .{ .generation = 1, .owner = 1, .attachment_generation = 1 },
308 .use_poll_recipe = false,
309 .target = .{ .hand = .{
310 .host = "unused.invalid",
311 .ssh_argv = &.{ "/bin/sh", "-c", "printf 'endpoint none\\n'; dd bs=5 count=1 of=/dev/null 2>/dev/null; printf '\\221\\002\\000\\000\\0000\\n'; cat >/dev/null" },
312 .asked_argv = &.{"false"},
313 .cache_path = null,
314 .asked = true,
315 } },
316 });
317 defer job.stop();
318 const result = try resultOf(job);
319 try std.testing.expectEqual(Phase.sessions, result.phase);
320 try std.testing.expectEqualStrings("0\n", result.text());
321 try std.testing.expect(!result.may_have_created);
322 }
src/gui/frame.zig
Old New
@@ -398,7 +398,7 @@ const Events = struct {
398 /// Commit events sample the actual drawable even if its resize notice 398 /// Commit events sample the actual drawable even if its resize notice
399 /// is still behind this event in SDL's bounded queue. 399 /// is still behind this event in SDL's bounded queue.
400 fn dispatch(self: *Events, ev: c.SDL_Event) !bool { 400 fn dispatch(self: *Events, ev: c.SDL_Event) !bool {
401 if ((ev.type == c.SDL_EVENT_KEY_DOWN and (self.ui.resize_mode or ev.key.key == c.SDLK_RETURN or ev.key.key == c.SDLK_KP_ENTER)) or ev.type == c.SDL_EVENT_MOUSE_BUTTON_DOWN or ev.type == c.SDL_EVENT_MOUSE_WHEEL or ((self.ui.hasPointerCapture()) and (ev.type == c.SDL_EVENT_MOUSE_MOTION or ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP))) { 401 if ((ev.type == c.SDL_EVENT_KEY_DOWN and (self.ui.command_mode or self.ui.resize_mode or ev.key.key == c.SDLK_RETURN or ev.key.key == c.SDLK_KP_ENTER)) or ev.type == c.SDL_EVENT_MOUSE_BUTTON_DOWN or ev.type == c.SDL_EVENT_MOUSE_WHEEL or ((self.ui.hasPointerCapture()) and (ev.type == c.SDL_EVENT_MOUSE_MOTION or ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP))) {
402 self.geometry_dirty = true; 402 self.geometry_dirty = true;
403 try self.refreshGeometry(); 403 try self.refreshGeometry();
404 } 404 }
@@ -406,7 +406,7 @@ const Events = struct {
406 } 406 }
407 fn handle(self: *Events, ev: c.SDL_Event) !bool { 407 fn handle(self: *Events, ev: c.SDL_Event) !bool {
408 defer self.syncCapture(); 408 defer self.syncCapture();
409 std.debug.assert(self.ui.picker == null or self.ui.recovery == null); 409 std.debug.assert(self.ui.modalPicker() == null or self.ui.recovery == null);
410 switch (ev.type) { 410 switch (ev.type) {
411 c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => return false, 411 c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => return false,
412 c.SDL_EVENT_TEXT_INPUT => { 412 c.SDL_EVENT_TEXT_INPUT => {
@@ -644,7 +644,7 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
644 var events: Events = .{ .win = win, .wake = &wake, .hook = if (hook) |*h| h else null, .cache = &cache, .base_font_px = opts.font_px, .base_font_points = opts.font_points, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = fb_w, .fb_h = fb_h, .key_path = opts.key_path, .local_target = opts.local_target, .store = if (store) |*s| s else null, .save_notice = load_notice, .save_notice_len = load_notice_len, .wake_ctx = &wake, .wake = Wake.discovery } }; 644 var events: Events = .{ .win = win, .wake = &wake, .hook = if (hook) |*h| h else null, .cache = &cache, .base_font_px = opts.font_px, .base_font_points = opts.font_points, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = fb_w, .fb_h = fb_h, .key_path = opts.key_path, .local_target = opts.local_target, .store = if (store) |*s| s else null, .save_notice = load_notice, .save_notice_len = load_notice_len, .wake_ctx = &wake, .wake = Wake.discovery } };
645 defer events.deinit(); 645 defer events.deinit();
646 try events.ui.relayout(); 646 try events.ui.relayout();
647 if (events.ui.layout.len == 0) try events.ui.openPicker(.insert); 647 if (events.ui.layout.len == 0) try events.ui.open(.insert);
648 var headers: [model.max_panes]Header = @splat(.{}); 648 var headers: [model.max_panes]Header = @splat(.{});
649 var popup: PopupFrame = .{}; 649 var popup: PopupFrame = .{};
650 var popup_lists: quads.Lists = .{}; 650 var popup_lists: quads.Lists = .{};
@@ -685,12 +685,11 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
685 } 685 }
686 try events.ui.pollEnd(); 686 try events.ui.pollEnd();
687 events.syncCapture(); 687 events.syncCapture();
688 if (events.ui.picker) |picker| if (picker.job) |job| if (job.done.load(.acquire)) { 688 if (events.ui.requestReady()) {
689 events.geometry_dirty = true; 689 events.geometry_dirty = true;
690 try events.refreshGeometry(); 690 try events.refreshGeometry();
691 events.ui.dirty = (try picker.poll()) or events.ui.dirty; 691 try events.ui.pollOpening();
692 }; 692 }
693 try events.ui.finishPicker();
694 events.ui.saveIntent(); 693 events.ui.saveIntent();
695 if (hook) |*h| if (h.state) |path| { 694 if (hook) |*h| if (h.state) |path| {
696 defer alloc.free(path); 695 defer alloc.free(path);
@@ -731,11 +730,11 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
731 try prepareRow(&cache, &header_row); 730 try prepareRow(&cache, &header_row);
732 } 731 }
733 popup.len = 0; 732 popup.len = 0;
734 if (events.ui.picker) |picker| { 733 if (events.ui.modalPicker()) |picker| {
735 popup.set(picker); 734 popup.set(picker);
736 } else if (events.ui.recovery) |menu| popup.setRecovery(menu, &events) else if (events.ui.layout.len == 0) popup.setEmpty(&events); 735 } else if (events.ui.recovery) |menu| popup.setRecovery(menu, &events) else if (events.ui.layout.len == 0) popup.setEmpty(&events);
737 if (events.ui.save_notice_len != 0 and popup.len >= 2) popup.lines[popup.len - 2].setText(events.ui.save_notice[0..events.ui.save_notice_len], popup.rect.w / events.ui.metrics.cell_w); 736 if (events.ui.save_notice_len != 0 and popup.len >= 2) popup.lines[popup.len - 2].setText(events.ui.save_notice[0..events.ui.save_notice_len], popup.rect.w / events.ui.metrics.cell_w);
738 if (events.ui.picker != null and events.ui.notice.len != 0 and popup.len >= 2) popup.lines[popup.len - 2].setText(events.ui.notice, popup.rect.w / events.ui.metrics.cell_w); 737 if (events.ui.modalPicker() != null and events.ui.notice.len != 0 and popup.len >= 2) popup.lines[popup.len - 2].setText(events.ui.notice, popup.rect.w / events.ui.metrics.cell_w);
739 for (popup.lines[0..popup.len]) |*line| { 738 for (popup.lines[0..popup.len]) |*line| {
740 var row = line.row(); 739 var row = line.row();
741 try prepareRow(&cache, &row); 740 try prepareRow(&cache, &row);
@@ -854,15 +853,15 @@ test "delayed End refusal cannot install a hidden force menu over an active pick
854 defer events.deinit(); 853 defer events.deinit();
855 try events.ui.relayout(); 854 try events.ui.relayout();
856 events.ui.pending_end = .{ .key = rt.get(id).?.key, .request = 1 }; 855 events.ui.pending_end = .{ .key = rt.get(id).?.key, .request = 1 };
857 try events.ui.openPicker(.insert); 856 try events.ui.open(.insert);
858 rt.get(id).?.pump.mu.lock(); 857 rt.get(id).?.pump.mu.lock();
859 rt.get(id).?.pump.status.ending = .{ .request = 1, .phase = .refused, .others = 1 }; 858 rt.get(id).?.pump.status.ending = .{ .request = 1, .phase = .refused, .others = 1 };
860 rt.get(id).?.pump.mu.unlock(); 859 rt.get(id).?.pump.mu.unlock();
861 _ = rt.poll(std.time.milliTimestamp()); 860 _ = rt.poll(std.time.milliTimestamp());
862 try events.ui.pollEnd(); 861 try events.ui.pollEnd();
863 try std.testing.expect(events.ui.recovery == null and events.ui.picker != null); 862 try std.testing.expect(events.ui.recovery == null and events.ui.modalPicker() != null);
864 try std.testing.expect(events.ui.pending_end == null); 863 try std.testing.expect(events.ui.pending_end == null);
865 events.ui.picker.?.level = .session_name; 864 events.ui.modalPicker().?.level = .session_name;
866 var ev = std.mem.zeroes(c.SDL_Event); 865 var ev = std.mem.zeroes(c.SDL_Event);
867 ev.key.type = c.SDL_EVENT_KEY_DOWN; 866 ev.key.type = c.SDL_EVENT_KEY_DOWN;
868 ev.key.key = c.SDLK_X; 867 ev.key.key = c.SDLK_X;
@@ -870,7 +869,7 @@ test "delayed End refusal cannot install a hidden force menu over an active pick
870 ev.text.type = c.SDL_EVENT_TEXT_INPUT; 869 ev.text.type = c.SDL_EVENT_TEXT_INPUT;
871 ev.text.text = "x"; 870 ev.text.text = "x";
872 _ = try events.handle(ev); 871 _ = try events.handle(ev);
873 try std.testing.expectEqualStrings("x", events.ui.picker.?.input.items); 872 try std.testing.expectEqualStrings("x", events.ui.modalPicker().?.input.items);
874 try std.testing.expectEqual(@as(u64, 1), rt.get(id).?.pump.state().ending.request); 873 try std.testing.expectEqual(@as(u64, 1), rt.get(id).?.pump.state().ending.request);
875 } 874 }
876 875
@@ -1023,7 +1022,8 @@ const Header = struct {
1023 const focused = events.ui.rt.workspace.tab().focus == p.id; 1022 const focused = events.ui.rt.workspace.tab().focus == p.id;
1024 const pending = events.ui.rt.workspace.tab().pending; 1023 const pending = events.ui.rt.workspace.tab().pending;
1025 const ending = if (events.ui.pending_end) |end| end.key.pane == p.id and events.ui.rt.accepts(end.key) else false; 1024 const ending = if (events.ui.pending_end) |end| end.key.pane == p.id and events.ui.rt.accepts(end.key) else false;
1026 const hint = if (events.ui.save_notice_len != 0) events.ui.save_notice[0..events.ui.save_notice_len] else if (ending) " [End requested; waiting for daemon]" else if (focused and events.ui.resize_mode) (if (events.ui.notice.len != 0) events.ui.notice else " [resize: arrows/hjkl move divider, Enter/Esc finish]") else if (focused and events.ui.command_mode) " [command: v below, b beside, h/j/k/l focus, r resize, Enter picks session, Esc cancel]" else if (pending != null and pending.?.pane == p.id) (if (events.ui.picker != null and events.ui.picker.?.mode == .insert) (if (pending.?.direction == .beside) " [split beside]" else " [split below]") else (if (pending.?.direction == .beside) " [split beside: prefix Enter chooses session, Esc cancels]" else " [split below: prefix Enter chooses session, Esc cancels]")) else events.ui.notice; 1025 const quick = events.ui.opening != null and events.ui.opening.?.mode == .quick;
1026 const hint = if (events.ui.save_notice_len != 0) events.ui.save_notice[0..events.ui.save_notice_len] else if (ending) " [End requested; waiting for daemon]" else if (focused and events.ui.resize_mode) (if (events.ui.notice.len != 0) events.ui.notice else " [resize: arrows/hjkl move divider, Enter/Esc finish]") else if (focused and events.ui.command_mode) " [command: v new below, b new beside, h/j/k/l focus, r resize, Enter picks session, Esc cancel]" else if (quick) events.ui.opening.?.noticeText() else if (pending != null and pending.?.pane == p.id) (if (events.ui.modalPicker() != null and events.ui.modalPicker().?.mode == .insert) (if (pending.?.direction == .beside) " [split beside]" else " [split below]") else (if (pending.?.direction == .beside) " [split beside: prefix Enter chooses session, Esc cancels]" else " [split below: prefix Enter chooses session, Esc cancels]")) else events.ui.notice;
1027 const label = events.ui.rt.workspace.pane(p.id).?.identity.label; 1027 const label = events.ui.rt.workspace.pane(p.id).?.identity.label;
1028 var status_buf: [48]u8 = undefined; 1028 var status_buf: [48]u8 = undefined;
1029 const status: []const u8 = switch (live.status.phase) { 1029 const status: []const u8 = switch (live.status.phase) {
@@ -1105,7 +1105,7 @@ fn writeState(alloc: std.mem.Allocator, path: []const u8, events: *Events) !void
1105 const PopupRow = struct { label: []const u8, rect: model.Rect }; 1105 const PopupRow = struct { label: []const u8, rect: model.Rect };
1106 const PopupState = struct { level: picker_mod.Level, rows: []const PopupRow, selected: usize, notice: []const u8, host: []const u8, input: []const u8, rect: model.Rect, row_height: u16, first: usize }; 1106 const PopupState = struct { level: picker_mod.Level, rows: []const PopupRow, selected: usize, notice: []const u8, host: []const u8, input: []const u8, rect: model.Rect, row_height: u16, first: usize };
1107 var picker_state: ?PopupState = null; 1107 var picker_state: ?PopupState = null;
1108 if (events.ui.picker) |picker| { 1108 if (events.ui.modalPicker()) |picker| {
1109 const view = picker.view(); 1109 const view = picker.view();
1110 const rows = try a.alloc(PopupRow, picker.rowCount()); 1110 const rows = try a.alloc(PopupRow, picker.rowCount());
1111 for (rows, 0..) |*row, i| row.* = .{ .label = picker.rowLabel(i), .rect = view.rowRect(i) }; 1111 for (rows, 0..) |*row, i| row.* = .{ .label = picker.rowLabel(i), .rect = view.rowRect(i) };
@@ -1119,7 +1119,8 @@ fn writeState(alloc: std.mem.Allocator, path: []const u8, events: *Events) !void
1119 for (rows, 0..) |*row, i| row.* = .{ .label = menu.label(i), .rect = view.rowRect(i) }; 1119 for (rows, 0..) |*row, i| row.* = .{ .label = menu.label(i), .rect = view.rowRect(i) };
1120 recovery_state = .{ .kind = @tagName(menu.kind), .rows = rows, .selected = menu.selected, .notice = menu.notice[0..menu.notice_len], .rect = view.rect }; 1120 recovery_state = .{ .kind = @tagName(menu.kind), .rows = rows, .selected = menu.selected, .notice = menu.notice[0..menu.notice_len], .rect = view.rect };
1121 } 1121 }
1122 const bytes = try std.json.Stringify.valueAlloc(a, .{ .width = events.ui.fb_w, .height = events.ui.fb_h, .logical_width = w, .logical_height = h, .cell_w = events.ui.metrics.cell_w, .cell_h = events.ui.metrics.cell_h, .divider = events.ui.metrics.divider, .header_h = events.ui.metrics.cell_h, .tab = events.ui.rt.workspace.active_tab_id, .focus = events.ui.rt.workspace.tab().focus, .pending = events.ui.rt.workspace.tab().pending, .command_mode = events.ui.command_mode, .resize_mode = events.ui.resize_mode, .drag = events.ui.drag, .dividers = events.ui.layout.boundaries(), .notice = if (events.ui.save_notice_len != 0) events.ui.save_notice[0..events.ui.save_notice_len] else events.ui.notice, .persistent = events.ui.store != null, .save_enabled = if (events.ui.store) |store| store.writable else false, .pending_end = events.ui.pending_end, .recovery = recovery_state, .picker = picker_state, .panes = panes[0..events.ui.layout.len] }, .{}); 1122 const quick = events.ui.opening != null and events.ui.opening.?.mode == .quick;
1123 const bytes = try std.json.Stringify.valueAlloc(a, .{ .width = events.ui.fb_w, .height = events.ui.fb_h, .logical_width = w, .logical_height = h, .cell_w = events.ui.metrics.cell_w, .cell_h = events.ui.metrics.cell_h, .divider = events.ui.metrics.divider, .header_h = events.ui.metrics.cell_h, .tab = events.ui.rt.workspace.active_tab_id, .focus = events.ui.rt.workspace.tab().focus, .pending = events.ui.rt.workspace.tab().pending, .command_mode = events.ui.command_mode, .resize_mode = events.ui.resize_mode, .drag = events.ui.drag, .dividers = events.ui.layout.boundaries(), .notice = if (events.ui.save_notice_len != 0) events.ui.save_notice[0..events.ui.save_notice_len] else if (quick) events.ui.opening.?.noticeText() else events.ui.notice, .persistent = events.ui.store != null, .save_enabled = if (events.ui.store) |store| store.writable else false, .pending_end = events.ui.pending_end, .recovery = recovery_state, .picker = picker_state, .opening = quick, .panes = panes[0..events.ui.layout.len] }, .{});
1123 try writeArtifact(a, path, &.{bytes}); 1124 try writeArtifact(a, path, &.{bytes});
1124 } 1125 }
1125 fn writePixels(alloc: std.mem.Allocator, path: []const u8, width: u32, height: u32, pixels: []const u8) !void { 1126 fn writePixels(alloc: std.mem.Allocator, path: []const u8, width: u32, height: u32, pixels: []const u8) !void {
@@ -1524,7 +1525,7 @@ const PopupFrame = struct {
1524 self.selected_line = 1; 1525 self.selected_line = 1;
1525 } else if (picker.level != .hosts) self.lines[self.len - 3].setText(picker.host(), cols); 1526 } else if (picker.level != .hosts) self.lines[self.len - 3].setText(picker.host(), cols);
1526 self.lines[self.len - 2].setText(picker.noticeText(), cols); 1527 self.lines[self.len - 2].setText(picker.noticeText(), cols);
1527 self.lines[self.len - 1].setText(if (editing) "Enter confirms | Esc goes back" else "Up/Down or j/k choose | Enter selects | Esc goes back", cols); 1528 self.lines[self.len - 1].setText(if (editing) "Enter confirms | Esc goes back" else if (picker.mode == .insert) "j/k choose | Enter selects | v below, b beside | Esc back" else "Up/Down or j/k choose | Enter selects | Esc goes back", cols);
1528 } 1529 }
1529 fn emit(self: *PopupFrame, lists: *quads.Lists, alloc: std.mem.Allocator, base: quads.Ctx) !void { 1530 fn emit(self: *PopupFrame, lists: *quads.Lists, alloc: std.mem.Allocator, base: quads.Ctx) !void {
1530 if (self.len == 0) return; 1531 if (self.len == 0) return;
@@ -1555,7 +1556,7 @@ test "modal Enter remains consumed through insertion and repeated keydown until
1555 const picker = try a.create(picker_mod.Picker); 1556 const picker = try a.create(picker_mod.Picker);
1556 picker.* = .{ .alloc = a, .arena = std.heap.ArenaAllocator.init(a), .rt = &rt, .origin = rt.get(id).?.key, .origin_tab = rt.workspace.active_tab_id, .pending = rt.workspace.tab().pending.?, .ticket = .{ .generation = 1, .owner = id, .attachment_generation = 1 }, .next_generation = &next, .key_path = null, .width = 800, .height = 600, .metrics = metrics, .wake = null, .wake_ctx = null }; 1557 picker.* = .{ .alloc = a, .arena = std.heap.ArenaAllocator.init(a), .rt = &rt, .origin = rt.get(id).?.key, .origin_tab = rt.workspace.active_tab_id, .pending = rt.workspace.tab().pending.?, .ticket = .{ .generation = 1, .owner = id, .attachment_generation = 1 }, .next_generation = &next, .key_path = null, .width = 800, .height = 600, .metrics = metrics, .wake = null, .wake_ctx = null };
1557 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; 1558 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
1558 var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .picker = picker, .wake_ctx = &wake, .wake = Wake.discovery } }; 1559 var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .opening = picker, .wake_ctx = &wake, .wake = Wake.discovery } };
1559 defer events.deinit(); 1560 defer events.deinit();
1560 try picker.hosts.append(picker.arena.allocator(), .{ .label = "fixture", .target = .{ .via = "cat" } }); 1561 try picker.hosts.append(picker.arena.allocator(), .{ .label = "fixture", .target = .{ .via = "cat" } });
1561 picker.level = .session_name; 1562 picker.level = .session_name;
@@ -1584,7 +1585,7 @@ test "modal Enter remains consumed through insertion and repeated keydown until
1584 event.key.type = c.SDL_EVENT_KEY_DOWN; 1585 event.key.type = c.SDL_EVENT_KEY_DOWN;
1585 event.key.key = c.SDLK_RETURN; 1586 event.key.key = c.SDLK_RETURN;
1586 try std.testing.expect(try events.handle(event)); 1587 try std.testing.expect(try events.handle(event));
1587 try std.testing.expect(events.ui.picker == null); 1588 try std.testing.expect(events.ui.modalPicker() == null);
1588 try std.testing.expectEqual(@as(usize, 2), events.ui.layout.len); 1589 try std.testing.expectEqual(@as(usize, 2), events.ui.layout.len);
1589 event.key.repeat = true; 1590 event.key.repeat = true;
1590 try std.testing.expect(try events.handle(event)); 1591 try std.testing.expect(try events.handle(event));
src/gui/interaction.zig
Old New
@@ -70,7 +70,7 @@ pub const Recovery = struct {
70 }; 70 };
71 pub const Controller = struct { 71 pub const Controller = struct {
72 rt: *runtime.Runtime, 72 rt: *runtime.Runtime,
73 picker: ?*picker_mod.Picker = null, 73 opening: ?*picker_mod.Picker = null,
74 next_request: u64 = 1, 74 next_request: u64 = 1,
75 key_path: ?[]const u8 = null, 75 key_path: ?[]const u8 = null,
76 local_target: ?client.Target = null, 76 local_target: ?client.Target = null,
@@ -109,7 +109,16 @@ pub const Controller = struct {
109 self.cancelDrag(); 109 self.cancelDrag();
110 self.clearSelection(); 110 self.clearSelection();
111 self.modal_held.deinit(self.rt.alloc); 111 self.modal_held.deinit(self.rt.alloc);
112 if (self.picker) |picker| picker.deinit(); 112 if (self.opening) |opening| {
113 opening.cancel();
114 if (opening.mode == .quick) std.debug.print("muxg: {s}\n", .{opening.noticeText()});
115 opening.deinit();
116 }
117 }
118 /// The modal view of the active request; quick opening leaves input live.
119 pub fn modalPicker(self: *const Controller) ?*picker_mod.Picker {
120 const opening = self.opening orelse return null;
121 return if (opening.mode == .quick) null else opening;
113 } 122 }
114 pub fn hasPointerCapture(self: *const Controller) bool { 123 pub fn hasPointerCapture(self: *const Controller) bool {
115 return self.drag != null or self.selection_drag.buttonHeld() or self.app_drag != null or self.local_held; 124 return self.drag != null or self.selection_drag.buttonHeld() or self.app_drag != null or self.local_held;
@@ -207,12 +216,17 @@ pub const Controller = struct {
207 self.intent_dirty = self.intent_dirty or before != ws.tab().focus; 216 self.intent_dirty = self.intent_dirty or before != ws.tab().focus;
208 } else switch (key) { 217 } else switch (key) {
209 .v, .b, .enter, .keypad_enter => { 218 .v, .b, .enter, .keypad_enter => {
210 if (key == .v or key == .b) ws.arm(if (key == .v) .stacked else .beside); 219 if (self.opening != null) {
211 self.openPicker(.insert) catch |err| {
212 self.notice = @errorName(err);
213 self.dirty = true; 220 self.dirty = true;
214 return; 221 return;
215 }; 222 }
223 if (key == .v or key == .b) {
224 ws.arm(if (key == .v) .stacked else .beside);
225 self.open(.quick) catch |err| {
226 ws.cancel();
227 self.setNotice(@errorName(err));
228 };
229 } else self.open(.insert) catch |err| self.setNotice(@errorName(err));
216 }, 230 },
217 .r => self.resize_mode = true, 231 .r => self.resize_mode = true,
218 .d => if (ws.tab().focus) |id| try self.detach(id), 232 .d => if (ws.tab().focus) |id| try self.detach(id),
@@ -221,33 +235,67 @@ pub const Controller = struct {
221 self.recovery = .{ .kind = .recovery, .key = self.rt.get(id).?.key }; 235 self.recovery = .{ .kind = .recovery, .key = self.rt.get(id).?.key };
222 self.recovery.?.setNotice(self.rt.get(id).?.status.reasonText()); 236 self.recovery.?.setNotice(self.rt.get(id).?.status.reasonText());
223 }, 237 },
224 .escape => ws.cancel(), 238 .escape => {
225 else => self.notice = "v below, b beside, hjkl focus, r resize, d detach, x end, p actions, Enter add", 239 if (self.opening) |opening| opening.cancel();
240 try self.finishOpening();
241 ws.cancel();
242 },
243 else => self.notice = "v new below, b new beside, hjkl focus, r resize, d detach, x end, p actions, Enter picks session",
226 } 244 }
227 self.dirty = true; 245 self.dirty = true;
228 } 246 }
229 247
230 pub fn finishPicker(self: *Controller) !void { 248 pub fn requestReady(self: *const Controller) bool {
231 if (self.picker) |picker| if (picker.closed) { 249 const opening = self.opening orelse return false;
232 const inserted = picker.inserted; 250 const job = opening.job orelse return false;
233 picker.deinit(); 251 return job.done.load(.acquire);
234 self.picker = null; 252 }
253 /// Geometry is refreshed by the frame before completed requests insert.
254 pub fn pollOpening(self: *Controller) !void {
255 const opening = self.opening orelse return;
256 self.dirty = (try opening.poll()) or self.dirty;
257 try self.finishOpening();
258 }
259 fn finishOpening(self: *Controller) !void {
260 const opening = self.opening orelse return;
261 if (!opening.closed) return;
262 const inserted = opening.inserted;
263 if (opening.mode == .quick) {
235 if (inserted) { 264 if (inserted) {
236 try self.relayout(); 265 self.notice = "";
237 self.intent_dirty = true; 266 } else {
267 self.setNotice(opening.noticeText());
268 if (self.rt.workspace.active_tab_id == opening.origin_tab and std.meta.eql(self.rt.workspace.tab().pending, opening.pending)) self.rt.workspace.cancel();
238 } 269 }
239 self.dirty = true; 270 }
240 }; 271 opening.deinit();
272 self.opening = null;
273 if (inserted) {
274 self.cancelDrag();
275 self.clearSelection();
276 try self.relayout();
277 self.intent_dirty = true;
278 }
279 self.dirty = true;
241 } 280 }
242 pub fn openPicker(self: *Controller, mode: picker_mod.Picker.Mode) !void { 281
282 pub fn open(self: *Controller, mode: picker_mod.Picker.Mode) !void {
283 if (self.opening != null) {
284 self.setNotice("A pane is opening; Esc cancels it");
285 return;
286 }
243 self.cancelDrag(); 287 self.cancelDrag();
244 self.clearSelection(); 288 self.clearSelection();
245 self.recovery = null; 289 self.recovery = null;
246 self.notice = ""; 290 self.notice = "";
247 const picker = try picker_mod.Picker.initMode(self.rt.alloc, self.rt, &self.next_request, self.key_path, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics, self.wake_ctx, self.wake, mode); 291 const actual_mode = if (mode == .quick and self.rt.workspace.tab().focus == null) .insert else mode;
248 errdefer picker.deinit(); 292 self.opening = blk: {
249 if (picker.origin == null) if (self.local_target) |target| try picker.includeTarget(target); 293 const opening = try picker_mod.Picker.initMode(self.rt.alloc, self.rt, &self.next_request, self.key_path, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics, self.wake_ctx, self.wake, actual_mode);
250 self.picker = picker; 294 errdefer opening.deinit();
295 if (opening.origin == null) if (self.local_target) |target| try opening.includeTarget(target);
296 break :blk opening;
297 };
298 try self.finishOpening();
251 self.dirty = true; 299 self.dirty = true;
252 } 300 }
253 pub fn setNotice(self: *Controller, text: []const u8) void { 301 pub fn setNotice(self: *Controller, text: []const u8) void {
@@ -259,6 +307,10 @@ pub const Controller = struct {
259 pub fn detach(self: *Controller, id: model.PaneId) !void { 307 pub fn detach(self: *Controller, id: model.PaneId) !void {
260 self.cancelDrag(); 308 self.cancelDrag();
261 self.resize_mode = false; 309 self.resize_mode = false;
310 if (self.opening) |opening| if (opening.mode == .quick and opening.origin.?.pane == id) {
311 opening.cancel();
312 try self.finishOpening();
313 };
262 if (self.pending_end) |pending| if (pending.key.pane == id) { 314 if (self.pending_end) |pending| if (pending.key.pane == id) {
263 self.pending_end = null; 315 self.pending_end = null;
264 self.setNotice("Pane detached; the pending End outcome is unknown"); 316 self.setNotice("Pane detached; the pending End outcome is unknown");
@@ -298,7 +350,7 @@ pub const Controller = struct {
298 .accepted => try self.detach(pending.key.pane), 350 .accepted => try self.detach(pending.key.pane),
299 .refused => { 351 .refused => {
300 self.setNotice(result.reasonText()); 352 self.setNotice(result.reasonText());
301 if (result.others > 0 and self.rt.workspace.tab().focus == pending.key.pane and self.picker == null and self.recovery == null and !self.resize_mode and self.drag == null and !self.command_mode) { 353 if (result.others > 0 and self.rt.workspace.tab().focus == pending.key.pane and self.opening == null and self.recovery == null and !self.resize_mode and self.drag == null and !self.command_mode) {
302 self.recovery = .{ .kind = .force_end, .key = pending.key }; 354 self.recovery = .{ .kind = .force_end, .key = pending.key };
303 self.recovery.?.setNotice(result.reasonText()); 355 self.recovery.?.setNotice(result.reasonText());
304 } 356 }
@@ -332,7 +384,7 @@ pub const Controller = struct {
332 self.intent_dirty = true; 384 self.intent_dirty = true;
333 } else { 385 } else {
334 _ = self.rt.workspace.focus(menu.key.pane); 386 _ = self.rt.workspace.focus(menu.key.pane);
335 try self.openPicker(.replace); 387 try self.open(.replace);
336 } 388 }
337 }, 389 },
338 2 => try self.detach(menu.key.pane), 390 2 => try self.detach(menu.key.pane),
@@ -362,9 +414,9 @@ pub const Controller = struct {
362 try self.recoveryAction(); 414 try self.recoveryAction();
363 break; 415 break;
364 }; 416 };
365 } else if (self.picker) |picker| { 417 } else if (self.modalPicker()) |picker| {
366 try picker.click(x, y); 418 try picker.click(x, y);
367 try self.finishPicker(); 419 try self.finishOpening();
368 } else if (self.layout.hitDivider(x, y, grab_x, grab_y)) |id| { 420 } else if (self.layout.hitDivider(x, y, grab_x, grab_y)) |id| {
369 const d = self.layout.divider(id).?; 421 const d = self.layout.divider(id).?;
370 const position = if (d.direction == .beside) x else y; 422 const position = if (d.direction == .beside) x else y;
@@ -380,14 +432,14 @@ pub const Controller = struct {
380 self.selection_key = live.key; 432 self.selection_key = live.key;
381 self.selection_version = live.snapshot_version; 433 self.selection_version = live.snapshot_version;
382 } 434 }
383 } else if (self.layout.len == 0) try self.openPicker(.insert); 435 } else if (self.layout.len == 0) try self.open(.insert);
384 } 436 }
385 self.dirty = true; 437 self.dirty = true;
386 } 438 }
387 439
388 pub fn mouseDown(self: *Controller, x: u32, y: u32, grab_x: u32, grab_y: u32, button: u8, mods: app_input.Mods) !void { 440 pub fn mouseDown(self: *Controller, x: u32, y: u32, grab_x: u32, grab_y: u32, button: u8, mods: app_input.Mods) !void {
389 if (self.hasPointerCapture() or button > 2 or self.command_mode) return; 441 if (self.hasPointerCapture() or button > 2 or self.command_mode) return;
390 if (self.picker != null or self.recovery != null or self.resize_mode or self.layout.hitDivider(x, y, grab_x, grab_y) != null) { 442 if (self.modalPicker() != null or self.recovery != null or self.resize_mode or self.layout.hitDivider(x, y, grab_x, grab_y) != null) {
391 if (button == 0) try self.pointerDown(x, y, grab_x, grab_y); 443 if (button == 0) try self.pointerDown(x, y, grab_x, grab_y);
392 return; 444 return;
393 } 445 }
@@ -429,7 +481,7 @@ pub const Controller = struct {
429 return; 481 return;
430 } 482 }
431 if (self.local_held or self.drag != null or self.selection_drag.buttonHeld()) return self.pointerMove(x, y); 483 if (self.local_held or self.drag != null or self.selection_drag.buttonHeld()) return self.pointerMove(x, y);
432 if (x < 0 or y < 0 or mods.shift or self.picker != null or self.recovery != null or self.resize_mode or self.command_mode) return; 484 if (x < 0 or y < 0 or mods.shift or self.modalPicker() != null or self.recovery != null or self.resize_mode or self.command_mode) return;
433 const id = self.layout.hit(@intCast(x), @intCast(y)) orelse return; 485 const id = self.layout.hit(@intCast(x), @intCast(y)) orelse return;
434 if (self.layout.hitDivider(@intCast(x), @intCast(y), 0, 0) != null) return; 486 if (self.layout.hitDivider(@intCast(x), @intCast(y), 0, 0) != null) return;
435 const p = self.layout.get(id) orelse return; 487 const p = self.layout.get(id) orelse return;
@@ -534,7 +586,7 @@ pub const Controller = struct {
534 /// Semantic wheel entry point. Transport routing is deliberately deferred 586 /// Semantic wheel entry point. Transport routing is deliberately deferred
535 /// until the pump has sampled the pane's current terminal modes. 587 /// until the pump has sampled the pane's current terminal modes.
536 pub fn wheel(self: *Controller, x: u32, y: u32, delta: f32, flipped: bool, mods: app_input.Mods) !void { 588 pub fn wheel(self: *Controller, x: u32, y: u32, delta: f32, flipped: bool, mods: app_input.Mods) !void {
537 if (self.picker != null or self.recovery != null or self.resize_mode or self.command_mode or self.drag != null) return; 589 if (self.modalPicker() != null or self.recovery != null or self.resize_mode or self.command_mode or self.drag != null) return;
538 const id = self.layout.hit(x, y) orelse return; 590 const id = self.layout.hit(x, y) orelse return;
539 const placement = self.layout.get(id) orelse return; 591 const placement = self.layout.get(id) orelse return;
540 if (!placement.content.contains(x, y) or placement.cols == 0 or placement.rows == 0 or self.metrics.cell_w == 0 or self.metrics.cell_h == 0) return; 592 if (!placement.content.contains(x, y) or placement.cols == 0 or placement.rows == 0 or self.metrics.cell_w == 0 or self.metrics.cell_h == 0) return;
@@ -581,7 +633,7 @@ pub const Controller = struct {
581 } 633 }
582 pub fn textInput(self: *Controller, text: []const u8) !void { 634 pub fn textInput(self: *Controller, text: []const u8) !void {
583 if (!self.suppress_text) { 635 if (!self.suppress_text) {
584 if (self.picker) |picker| { 636 if (self.modalPicker()) |picker| {
585 try picker.text(text); 637 try picker.text(text);
586 self.dirty = true; 638 self.dirty = true;
587 } else if (!self.command_mode and !self.resize_mode and self.recovery == null) { 639 } else if (!self.command_mode and !self.resize_mode and self.recovery == null) {
@@ -596,7 +648,7 @@ pub const Controller = struct {
596 pub fn pasteShortcut(self: *Controller, key: u32, text: []const u8) !void { 648 pub fn pasteShortcut(self: *Controller, key: u32, text: []const u8) !void {
597 self.consumeShortcut(key); 649 self.consumeShortcut(key);
598 if (text.len == 0) return; 650 if (text.len == 0) return;
599 if (self.picker) |picker| { 651 if (self.modalPicker()) |picker| {
600 try picker.text(text); 652 try picker.text(text);
601 self.dirty = true; 653 self.dirty = true;
602 } else if (!self.command_mode and !self.resize_mode and self.recovery == null) { 654 } else if (!self.command_mode and !self.resize_mode and self.recovery == null) {
@@ -627,19 +679,19 @@ pub const Controller = struct {
627 self.fb_w = w; 679 self.fb_w = w;
628 self.fb_h = h; 680 self.fb_h = h;
629 self.metrics = metrics; 681 self.metrics = metrics;
630 if (self.picker) |picker| { 682 if (self.opening) |opening| {
631 picker.width = @intCast(@max(w, 0)); 683 opening.width = @intCast(@max(w, 0));
632 picker.height = @intCast(@max(h, 0)); 684 opening.height = @intCast(@max(h, 0));
633 picker.metrics = metrics; 685 opening.metrics = metrics;
634 } 686 }
635 try self.relayout(); 687 try self.relayout();
636 } 688 }
637 pub fn keyDown(self: *Controller, input: KeyDown) !void { 689 pub fn keyDown(self: *Controller, input: KeyDown) !void {
638 std.debug.assert(self.picker == null or self.recovery == null); 690 std.debug.assert(self.modalPicker() == null or self.recovery == null);
639 self.suppress_text = false; 691 self.suppress_text = false;
640 const key = input.code; 692 const key = input.code;
641 const kind = input.kind; 693 const kind = input.kind;
642 if (!self.resize_mode and self.recovery == null and self.picker == null and self.modal_held.contains(key)) { 694 if (!self.resize_mode and self.recovery == null and self.modalPicker() == null and self.modal_held.contains(key)) {
643 self.suppress_text = true; 695 self.suppress_text = true;
644 return; 696 return;
645 } 697 }
@@ -688,8 +740,14 @@ pub const Controller = struct {
688 self.dirty = true; 740 self.dirty = true;
689 return; 741 return;
690 } 742 }
691 if (self.picker) |picker| { 743 if (self.modalPicker()) |picker| {
692 try self.modal_held.put(self.rt.alloc, key, {}); 744 try self.modal_held.put(self.rt.alloc, key, {});
745 if (!input.modified and (kind == .v or kind == .b) and picker.mode == .insert and (picker.level == .hosts or picker.level == .sessions)) {
746 picker.setDirection(if (kind == .v) .stacked else .beside);
747 self.suppress_text = true;
748 self.dirty = true;
749 return;
750 }
693 const mapped = menuKey(kind, picker.level != .hosts and picker.level != .sessions); 751 const mapped = menuKey(kind, picker.level != .hosts and picker.level != .sessions);
694 if (mapped) |k| { 752 if (mapped) |k| {
695 self.suppress_text = true; 753 self.suppress_text = true;
@@ -697,7 +755,7 @@ pub const Controller = struct {
697 try picker.key(k); 755 try picker.key(k);
698 } else if (input.modified) self.suppress_text = true; 756 } else if (input.modified) self.suppress_text = true;
699 self.dirty = true; 757 self.dirty = true;
700 try self.finishPicker(); 758 try self.finishOpening();
701 return; 759 return;
702 } 760 }
703 if (input.prefix) { 761 if (input.prefix) {
@@ -715,6 +773,10 @@ pub const Controller = struct {
715 self.consumed_key = key; 773 self.consumed_key = key;
716 self.suppress_text = true; 774 self.suppress_text = true;
717 try self.command(kind); 775 try self.command(kind);
776 } else if (kind == .escape and self.opening != null) {
777 self.consumeShortcut(key);
778 self.opening.?.cancel();
779 try self.finishOpening();
718 } else if (kind == .escape and self.rt.workspace.tab().pending != null) { 780 } else if (kind == .escape and self.rt.workspace.tab().pending != null) {
719 self.rt.workspace.cancel(); 781 self.rt.workspace.cancel();
720 self.dirty = true; 782 self.dirty = true;
@@ -776,13 +838,13 @@ test "paste shortcut follows the active text destination" {
776 defer picker.arena.deinit(); 838 defer picker.arena.deinit();
777 defer picker.input.deinit(a); 839 defer picker.input.deinit(a);
778 picker.level = .session_name; 840 picker.level = .session_name;
779 ui.picker = &picker; 841 ui.opening = &picker;
780 defer ui.picker = null; 842 defer ui.opening = null;
781 try ui.pasteShortcut(86, "new-shell"); 843 try ui.pasteShortcut(86, "new-shell");
782 try std.testing.expectEqualStrings("new-shell", picker.input.items); 844 try std.testing.expectEqualStrings("new-shell", picker.input.items);
783 try std.testing.expectEqual(@as(?u32, 86), ui.consumed_key); 845 try std.testing.expectEqual(@as(?u32, 86), ui.consumed_key);
784 ui.keyUp(86); 846 ui.keyUp(86);
785 ui.picker = null; 847 ui.opening = null;
786 ui.command_mode = true; 848 ui.command_mode = true;
787 try ui.pasteShortcut(86, "not terminal input"); 849 try ui.pasteShortcut(86, "not terminal input");
788 try std.testing.expect(ui.command_mode and ui.consumed_key == 86); 850 try std.testing.expect(ui.command_mode and ui.consumed_key == 86);
@@ -833,29 +895,53 @@ test "controller modal input takes precedence over the prefix" {
833 try std.testing.expect(ui.resize_mode and !ui.command_mode and ui.suppress_text); 895 try std.testing.expect(ui.resize_mode and !ui.command_mode and ui.suppress_text);
834 } 896 }
835 897
836 test "v opens below and b opens beside while Esc preserves then cancels direction" { 898 test "quick splits are nonmodal and cancellable; picker directions remain explicit" {
837 var rt = runtime.Runtime.init(std.testing.allocator, .{}); 899 var rt = runtime.Runtime.init(std.testing.allocator, .{});
838 defer rt.deinit(); 900 defer rt.deinit();
839 var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 }; 901 var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 };
840 defer ui.deinit(); 902 defer ui.deinit();
841 903
842 _ = try rt.add(.{ .via = "cat" }, "origin", 800, 600, ui.metrics); 904 // An empty workspace still needs an explicit target choice.
843 try ui.command(.v); 905 try ui.command(.v);
844 try std.testing.expect(ui.picker != null); 906 try std.testing.expect(ui.modalPicker() != null);
845 try std.testing.expectEqual(model.Direction.stacked, rt.workspace.tab().pending.?.direction);
846 try ui.keyDown(.{ .code = 27, .kind = .escape }); 907 try ui.keyDown(.{ .code = 27, .kind = .escape });
847 ui.keyUp(27); 908 ui.keyUp(27);
848 try std.testing.expect(ui.picker == null);
849 try std.testing.expectEqual(model.Direction.stacked, rt.workspace.tab().pending.?.direction);
850 909
910 const id = try rt.add(.{ .via = "cat" }, "origin", 800, 600, ui.metrics);
911 try ui.command(.v);
912 try std.testing.expect(ui.modalPicker() == null and ui.opening != null);
913 try std.testing.expectEqual(model.Direction.stacked, rt.workspace.tab().pending.?.direction);
914 try std.testing.expectEqual(id, ui.opening.?.origin.?.pane);
915 try ui.keyDown(.{ .code = 65 });
916 try std.testing.expect(!ui.suppress_text and ui.modal_held.count() == 0);
917 ui.keyUp(65);
918 const request = ui.opening.?.ticket;
919 try ui.command(.b);
920 try std.testing.expectEqual(request, ui.opening.?.ticket);
851 try ui.keyDown(.{ .code = 27, .kind = .escape }); 921 try ui.keyDown(.{ .code = 27, .kind = .escape });
852 ui.keyUp(27); 922 ui.keyUp(27);
853 try std.testing.expect(rt.workspace.tab().pending == null); 923 try std.testing.expect(ui.opening == null and rt.workspace.tab().pending == null);
854 924
855 try ui.command(.b); 925 try ui.command(.b);
856 try std.testing.expectEqual(model.Direction.beside, rt.workspace.tab().pending.?.direction); 926 try std.testing.expectEqual(model.Direction.beside, ui.opening.?.pending.?.direction);
927 try ui.command(.escape);
928
929 try ui.command(.enter);
930 try ui.keyDown(.{ .code = 118, .kind = .v });
931 ui.keyUp(118);
932 try std.testing.expectEqual(model.Direction.stacked, rt.workspace.tab().pending.?.direction);
933 try std.testing.expectEqual(rt.workspace.tab().pending, ui.modalPicker().?.pending);
857 try ui.keyDown(.{ .code = 27, .kind = .escape }); 934 try ui.keyDown(.{ .code = 27, .kind = .escape });
858 ui.keyUp(27); 935 ui.keyUp(27);
936 try std.testing.expect(ui.modalPicker() == null and rt.workspace.tab().pending != null);
937 try ui.keyDown(.{ .code = 27, .kind = .escape });
938 ui.keyUp(27);
939 try std.testing.expect(rt.workspace.tab().pending == null);
940
941 try ui.updateGeometry(1, 1, ui.metrics);
942 try ui.command(.b);
943 try std.testing.expect(ui.opening == null and rt.workspace.tab().pending == null);
944 try std.testing.expectEqualStrings("TooSmall", ui.notice);
859 } 945 }
860 946
861 test "beginEnd records pending request without opening an ending modal" { 947 test "beginEnd records pending request without opening an ending modal" {
src/gui/picker.zig
Old New
@@ -1,5 +1,6 @@
1 //! Modal host/session selection policy. No window calls or transport IO run 1 //! Host/session opening policy, with a modal picker or automatic naming on
2 //! here: discovery jobs own IO, while this object owns their UI generations. 2 //! the focused target. Discovery jobs own IO; requests retain their destination
3 //! and attachment generation until creation and pane insertion finish.
3 const std = @import("std"); 4 const std = @import("std");
4 const client = @import("client"); 5 const client = @import("client");
5 const proto = @import("term").protocol; 6 const proto = @import("term").protocol;
@@ -20,7 +21,7 @@ pub const View = struct {
20 } 21 }
21 }; 22 };
22 pub const Picker = struct { 23 pub const Picker = struct {
23 pub const Mode = enum { insert, replace }; 24 pub const Mode = enum { insert, replace, quick };
24 alloc: std.mem.Allocator, 25 alloc: std.mem.Allocator,
25 arena: std.heap.ArenaAllocator, 26 arena: std.heap.ArenaAllocator,
26 rt: *runtime.Runtime, 27 rt: *runtime.Runtime,
@@ -50,21 +51,32 @@ pub const Picker = struct {
50 metrics: model.Metrics, 51 metrics: model.Metrics,
51 closed: bool = false, 52 closed: bool = false,
52 inserted: bool = false, 53 inserted: bool = false,
54 collisions: usize = 0,
53 55
54 pub fn init(alloc: std.mem.Allocator, rt: *runtime.Runtime, next_generation: *u64, key_path: ?[]const u8, width: u32, height: u32, metrics: model.Metrics, wake_ctx: ?*anyopaque, wake: ?*const fn (?*anyopaque, discovery.Ticket) void) !*Picker { 56 pub fn init(alloc: std.mem.Allocator, rt: *runtime.Runtime, next_generation: *u64, key_path: ?[]const u8, width: u32, height: u32, metrics: model.Metrics, wake_ctx: ?*anyopaque, wake: ?*const fn (?*anyopaque, discovery.Ticket) void) !*Picker {
55 return initMode(alloc, rt, next_generation, key_path, width, height, metrics, wake_ctx, wake, .insert); 57 return initMode(alloc, rt, next_generation, key_path, width, height, metrics, wake_ctx, wake, .insert);
56 } 58 }
57 pub fn initMode(alloc: std.mem.Allocator, rt: *runtime.Runtime, next_generation: *u64, key_path: ?[]const u8, width: u32, height: u32, metrics: model.Metrics, wake_ctx: ?*anyopaque, wake: ?*const fn (?*anyopaque, discovery.Ticket) void, mode: Mode) !*Picker { 59 pub fn initMode(alloc: std.mem.Allocator, rt: *runtime.Runtime, next_generation: *u64, key_path: ?[]const u8, width: u32, height: u32, metrics: model.Metrics, wake_ctx: ?*anyopaque, wake: ?*const fn (?*anyopaque, discovery.Ticket) void, mode: Mode) !*Picker {
58 if (mode == .insert and rt.workspace.tab().pending == null) rt.workspace.arm(.beside); 60 if (mode != .replace and rt.workspace.tab().pending == null) rt.workspace.arm(.beside);
59 const pending = rt.workspace.tab().pending; 61 const pending = rt.workspace.tab().pending;
60 const id = if (mode == .replace) rt.workspace.tab().focus else if (pending) |p| p.pane else null; 62 const id = if (mode == .replace) rt.workspace.tab().focus else if (pending) |p| p.pane else null;
61 const pane = if (id) |p| rt.workspace.pane(p) orelse return error.MissingPane else null; 63 const pane = if (id) |p| rt.workspace.pane(p) orelse return error.MissingPane else null;
62 if (mode == .replace and pane == null) return error.MissingPane; 64 if (mode != .insert and pane == null) return error.MissingPane;
63 const origin: ?model.Attachment = if (pane) |p| .{ .pane = p.id, .generation = p.generation } else null; 65 const origin: ?model.Attachment = if (pane) |p| .{ .pane = p.id, .generation = p.generation } else null;
64 const self = try alloc.create(Picker); 66 const self = try alloc.create(Picker);
65 self.* = .{ .alloc = alloc, .arena = std.heap.ArenaAllocator.init(alloc), .rt = rt, .origin = origin, .origin_tab = rt.workspace.active_tab_id, .pending = pending, .mode = mode, .ticket = .{ .generation = next_generation.*, .owner = if (origin) |v| v.pane else 0, .attachment_generation = if (origin) |v| v.generation else 0 }, .next_generation = next_generation, .key_path = key_path, .width = width, .height = height, .metrics = metrics, .wake_ctx = wake_ctx, .wake = wake }; 67 self.* = .{ .alloc = alloc, .arena = std.heap.ArenaAllocator.init(alloc), .rt = rt, .origin = origin, .origin_tab = rt.workspace.active_tab_id, .pending = pending, .mode = mode, .ticket = .{ .generation = next_generation.*, .owner = if (origin) |v| v.pane else 0, .attachment_generation = if (origin) |v| v.generation else 0 }, .next_generation = next_generation, .key_path = key_path, .width = width, .height = height, .metrics = metrics, .wake_ctx = wake_ctx, .wake = wake };
66 next_generation.* += 1; 68 next_generation.* += 1;
67 errdefer self.deinit(); 69 errdefer self.deinit();
70 if (mode == .quick) {
71 // Check geometry before any request, and retain the exact target,
72 // including custom socket, key and SSH arguments.
73 _ = try self.preflight();
74 try self.includeTarget(pane.?.identity.target);
75 const text_ = std.fmt.bufPrint(&self.notice, "Opening on {s}... (Esc cancels)", .{self.host()}) catch self.notice[0..];
76 self.notice_len = text_.len;
77 try self.list();
78 return self;
79 }
68 const a = self.arena.allocator(); 80 const a = self.arena.allocator();
69 self.catalogue = client.hosts.statePath(a) catch null; 81 self.catalogue = client.hosts.statePath(a) catch null;
70 if (self.catalogue) |path| { 82 if (self.catalogue) |path| {
@@ -101,7 +113,7 @@ pub const Picker = struct {
101 if (!found) try self.hosts.append(a, .{ .label = try a.dupe(u8, label), .target = try discovery.cloneTarget(a, current) }); 113 if (!found) try self.hosts.append(a, .{ .label = try a.dupe(u8, label), .target = try discovery.cloneTarget(a, current) });
102 } 114 }
103 pub fn deinit(self: *Picker) void { 115 pub fn deinit(self: *Picker) void {
104 if (self.job) |job| job.stop(); 116 if (self.job != null) self.cancel();
105 self.input.deinit(self.alloc); 117 self.input.deinit(self.alloc);
106 self.arena.deinit(); 118 self.arena.deinit();
107 self.alloc.destroy(self); 119 self.alloc.destroy(self);
@@ -183,7 +195,32 @@ pub const Picker = struct {
183 return; 195 return;
184 }; 196 };
185 } 197 }
198 pub fn setDirection(self: *Picker, direction: model.Direction) void {
199 if (self.mode != .insert or (self.level != .hosts and self.level != .sessions) or !self.validOrigin()) return;
200 if (self.pending) |*pending| {
201 pending.direction = direction;
202 self.rt.workspace.tab().pending = pending.*;
203 }
204 }
205 pub fn cancel(self: *Picker) void {
206 self.notice_len = 0;
207 self.level = .hosts;
208 self.selected = self.host_index;
209 self.closed = self.mode == .quick;
210 if (self.closed) self.setNotice("Pane opening cancelled");
211 if (self.job) |job| {
212 job.cancel();
213 const result = self.finishJob();
214 self.ticket.generation = self.next_generation.*;
215 self.next_generation.* += 1;
216 if (result.may_have_created) {
217 self.level = .session_name;
218 self.outcomeNotice(result.phase == .created, "Esc refreshes sessions; no automatic retry.");
219 }
220 }
221 }
186 fn back(self: *Picker) !void { 222 fn back(self: *Picker) !void {
223 if (self.mode == .quick) return self.cancel();
187 self.notice_len = 0; 224 self.notice_len = 0;
188 switch (self.level) { 225 switch (self.level) {
189 .hosts => self.closed = true, 226 .hosts => self.closed = true,
@@ -192,22 +229,7 @@ pub const Picker = struct {
192 self.selected = self.host_index; 229 self.selected = self.host_index;
193 }, 230 },
194 .session_name => try self.list(), 231 .session_name => try self.list(),
195 .busy => { 232 .busy => self.cancel(),
196 const job = self.job.?;
197 job.cancel();
198 const result = job.join();
199 job.stop();
200 self.job = null;
201 self.ticket.generation = self.next_generation.*;
202 self.next_generation.* += 1;
203 if (result.may_have_created) {
204 self.level = .session_name;
205 self.outcomeNotice(result.phase == .created, "Esc refreshes sessions; no automatic retry.");
206 } else {
207 self.level = .hosts;
208 self.selected = self.host_index;
209 }
210 },
211 } 233 }
212 } 234 }
213 fn choose(self: *Picker) !void { 235 fn choose(self: *Picker) !void {
@@ -256,21 +278,23 @@ pub const Picker = struct {
256 self.host_index = self.hosts.items.len - 1; 278 self.host_index = self.hosts.items.len - 1;
257 try self.list(); 279 try self.list();
258 }, 280 },
259 .session_name => { 281 .session_name => try self.create(),
260 const name = self.input.items;
261 if (!proto.validSessionName(name)) {
262 self.setNotice("Choose a valid session name (no spaces)");
263 return;
264 }
265 const placement = self.preflight() catch |err| {
266 self.setNotice(@errorName(err));
267 return;
268 };
269 try self.start(.{ .create = .{ .name = name, .cols = placement.cols, .rows = placement.rows } });
270 },
271 .busy => {}, 282 .busy => {},
272 } 283 }
273 } 284 }
285 fn create(self: *Picker) !void {
286 const name = self.input.items;
287 if (!proto.validSessionName(name)) {
288 self.setNotice("Choose a valid session name (no spaces)");
289 return;
290 }
291 const placement = self.preflight() catch |err| {
292 self.setNotice(@errorName(err));
293 self.closed = self.mode == .quick;
294 return;
295 };
296 try self.start(.{ .create = .{ .name = name, .cols = placement.cols, .rows = placement.rows } });
297 }
274 fn validOrigin(self: *Picker) bool { 298 fn validOrigin(self: *Picker) bool {
275 if (self.rt.workspace.active_tab_id != self.origin_tab) return false; 299 if (self.rt.workspace.active_tab_id != self.origin_tab) return false;
276 if (self.origin) |origin| { 300 if (self.origin) |origin| {
@@ -303,16 +327,22 @@ pub const Picker = struct {
303 return; 327 return;
304 }; 328 };
305 } else { 329 } else {
330 const focus = self.rt.workspace.tab().focus;
306 _ = self.rt.addWithPolicy(target, name, self.width, self.height, self.metrics, true) catch |err| { 331 _ = self.rt.addWithPolicy(target, name, self.width, self.height, self.metrics, true) catch |err| {
307 self.insertionFailed(err, created); 332 self.insertionFailed(err, created);
308 return; 333 return;
309 }; 334 };
335 // Typing may have moved to a different pane during discovery.
336 if (self.mode == .quick and focus != self.origin.?.pane) if (focus) |id| {
337 _ = self.rt.workspace.focus(id);
338 };
310 } 339 }
311 self.inserted = true; 340 self.inserted = true;
312 self.closed = true; 341 self.closed = true;
313 } 342 }
314 fn insertionFailed(self: *Picker, err: anyerror, created: bool) void { 343 fn insertionFailed(self: *Picker, err: anyerror, created: bool) void {
315 if (created) self.outcomeNotice(true, "Pane insertion failed. Esc refreshes available sessions.") else self.setNotice(@errorName(err)); 344 if (created) self.outcomeNotice(true, "Pane insertion failed. Esc refreshes available sessions.") else self.setNotice(@errorName(err));
345 self.closed = self.mode == .quick;
316 } 346 }
317 fn list(self: *Picker) !void { 347 fn list(self: *Picker) !void {
318 if (self.hosts.items[self.host_index].target == null) { 348 if (self.hosts.items[self.host_index].target == null) {
@@ -327,8 +357,9 @@ pub const Picker = struct {
327 std.debug.assert(self.job == null); 357 std.debug.assert(self.job == null);
328 self.ticket.generation = self.next_generation.*; 358 self.ticket.generation = self.next_generation.*;
329 self.next_generation.* += 1; 359 self.next_generation.* += 1;
330 self.job = discovery.Job.start(self.alloc, .{ .ticket = self.ticket, .target = self.hosts.items[self.host_index].target.?, .operation = operation, .wake_ctx = self.wake_ctx, .wake = self.wake }) catch |err| { 360 self.job = discovery.Job.start(self.alloc, .{ .ticket = self.ticket, .target = self.hosts.items[self.host_index].target.?, .operation = operation, .use_poll_recipe = self.mode != .quick, .wake_ctx = self.wake_ctx, .wake = self.wake }) catch |err| {
331 self.setNotice(@errorName(err)); 361 self.setNotice(@errorName(err));
362 self.closed = self.mode == .quick;
332 return; 363 return;
333 }; 364 };
334 self.level = .busy; 365 self.level = .busy;
@@ -336,21 +367,33 @@ pub const Picker = struct {
336 pub fn poll(self: *Picker) !bool { 367 pub fn poll(self: *Picker) !bool {
337 const job = self.job orelse return false; 368 const job = self.job orelse return false;
338 if (!job.done.load(.acquire)) return false; 369 if (!job.done.load(.acquire)) return false;
339 const result = job.join();
340 const creating = job.opts.operation == .create; 370 const creating = job.opts.operation == .create;
371 try self.applyResult(self.finishJob(), creating);
372 return true;
373 }
374 fn finishJob(self: *Picker) discovery.Result {
375 const job = self.job.?;
376 const result = job.join();
341 job.stop(); 377 job.stop();
342 self.job = null; 378 self.job = null;
343 try self.applyResult(result, creating); 379 return result;
344 return true;
345 } 380 }
346 fn applyResult(self: *Picker, result: discovery.Result, creating: bool) !void { 381 fn applyResult(self: *Picker, result: discovery.Result, creating: bool) !void {
347 if (!std.meta.eql(result.ticket, self.ticket) or !self.validOrigin()) { 382 if (!std.meta.eql(result.ticket, self.ticket) or !self.validOrigin()) {
348 self.level = .hosts; 383 self.level = .hosts;
349 if (result.phase == .created) self.outcomeNotice(true, "Destination changed. Refresh sessions to choose it.") else if (result.may_have_created) self.outcomeNotice(false, "Destination changed. Refresh sessions before retrying.") else self.setNotice("The pane for this request changed"); 384 if (result.phase == .created) self.outcomeNotice(true, "Destination changed. Refresh sessions to choose it.") else if (result.may_have_created) self.outcomeNotice(false, "Destination changed. Refresh sessions before retrying.") else self.setNotice("The pane for this request changed");
385 self.closed = self.mode == .quick;
350 return; 386 return;
351 } 387 }
352 switch (result.phase) { 388 switch (result.phase) {
353 .sessions => { 389 .sessions => {
390 if (self.mode == .quick) {
391 var buf: [proto.session_name_max]u8 = undefined;
392 self.input.clearRetainingCapacity();
393 try self.input.appendSlice(self.alloc, client.nextFreeName(&buf, result.text()));
394 try self.create();
395 return;
396 }
354 self.session_count = 0; 397 self.session_count = 0;
355 var iter = proto.sessionsIter(result.text()); 398 var iter = proto.sessionsIter(result.text());
356 while (iter.next()) |name| { 399 while (iter.next()) |name| {
@@ -366,16 +409,33 @@ pub const Picker = struct {
366 self.level = .session_name; 409 self.level = .session_name;
367 try self.insert(self.input.items, true); 410 try self.insert(self.input.items, true);
368 }, 411 },
412 .exists => {
413 // A definite collision is safe to retry. A lost reply is not:
414 // it may already have created the shell under this name.
415 if (self.mode == .quick and self.collisions < proto.sessions_max) {
416 self.collisions += 1;
417 try self.list();
418 } else {
419 self.level = .session_name;
420 self.closed = self.mode == .quick;
421 self.setNotice(if (self.mode == .quick) "Session names keep changing; try again" else result.reasonText());
422 }
423 },
369 else => { 424 else => {
370 self.level = if (creating) .session_name else .sessions; 425 self.level = if (creating) .session_name else .sessions;
371 if (!creating) self.session_count = 0; 426 if (!creating) self.session_count = 0;
372 if (result.may_have_created) self.outcomeNotice(false, "Esc refreshes sessions; no automatic retry.") else self.setNotice(result.reasonText()); 427 if (result.may_have_created) self.outcomeNotice(false, "Esc refreshes sessions; no automatic retry.") else self.setNotice(result.reasonText());
428 self.closed = self.mode == .quick;
373 }, 429 },
374 } 430 }
375 return; 431 return;
376 } 432 }
377 fn outcomeNotice(self: *Picker, created: bool, suffix: []const u8) void { 433 fn outcomeNotice(self: *Picker, created: bool, suffix: []const u8) void {
378 const message = std.fmt.bufPrint(&self.notice, "{s} '{s}'. {s}", .{ if (created) "Session created" else "Creation outcome unknown for", self.input.items, suffix }) catch self.notice[0..]; 434 const outcome = if (created) "Session created" else "Creation outcome unknown for";
435 const message = if (self.mode == .quick)
436 std.fmt.bufPrint(&self.notice, "{s} '{s}' on {s}. Prefix Enter to check sessions.", .{ outcome, self.input.items, self.host() }) catch self.notice[0..]
437 else
438 std.fmt.bufPrint(&self.notice, "{s} '{s}'. {s}", .{ outcome, self.input.items, suffix }) catch self.notice[0..];
379 self.notice_len = message.len; 439 self.notice_len = message.len;
380 } 440 }
381 fn setNotice(self: *Picker, text_: []const u8) void { 441 fn setNotice(self: *Picker, text_: []const u8) void {
@@ -426,3 +486,62 @@ test "picker validates fit before create and preserves remote outcome after orig
426 try std.testing.expect(std.mem.indexOf(u8, picker.noticeText(), "Session created 'new-shell'") != null); 486 try std.testing.expect(std.mem.indexOf(u8, picker.noticeText(), "Session created 'new-shell'") != null);
427 try std.testing.expectEqual(@as(usize, 1), rt.workspace.layout(800, 600, metrics).len); 487 try std.testing.expectEqual(@as(usize, 1), rt.workspace.layout(800, 600, metrics).len);
428 } 488 }
489
490 test "quick creation retries collisions but retains unknown outcomes and its original destination" {
491 const a = std.testing.allocator;
492 var rt = runtime.Runtime.init(a, .{});
493 defer rt.deinit();
494 const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 };
495 const id = try rt.add(.{ .via = "cat" }, "origin", 1200, 900, metrics);
496 const neighbor = try rt.add(.{ .via = "cat" }, "neighbor", 1200, 900, metrics);
497 _ = rt.workspace.focus(id);
498 rt.workspace.arm(.stacked);
499 var next: u64 = 2;
500 var opening: Picker = .{ .alloc = a, .arena = std.heap.ArenaAllocator.init(a), .rt = &rt, .origin = rt.get(id).?.key, .origin_tab = rt.workspace.active_tab_id, .pending = rt.workspace.tab().pending, .mode = .quick, .ticket = .{ .generation = 1, .owner = id, .attachment_generation = 1 }, .next_generation = &next, .key_path = null, .width = 1200, .height = 900, .metrics = metrics, .wake = null, .wake_ctx = null };
501 defer opening.arena.deinit();
502 defer opening.input.deinit(a);
503 defer if (opening.job) |job| job.stop();
504 try opening.includeTarget(rt.workspace.pane(id).?.identity.target);
505 var result: discovery.Result = .{ .ticket = opening.ticket, .phase = .sessions };
506 const names = "0\n2\n";
507 @memcpy(result.bytes[0..names.len], names);
508 result.len = names.len;
509 try opening.applyResult(result, false);
510 try std.testing.expectEqualStrings("1", opening.job.?.opts.operation.create.name);
511 opening.job.?.stop();
512 opening.job = null;
513 try opening.applyResult(.{ .ticket = opening.ticket, .phase = .exists }, true);
514 try std.testing.expect(opening.job.?.opts.operation == .list and !opening.closed);
515 opening.job.?.stop();
516 opening.job = null;
517 const updated = "0\n1\n2\n";
518 @memcpy(result.bytes[0..updated.len], updated);
519 result.len = updated.len;
520 result.ticket = opening.ticket;
521 try opening.applyResult(result, false);
522 try std.testing.expectEqualStrings("3", opening.job.?.opts.operation.create.name);
523 opening.job.?.stop();
524 opening.job = null;
525 try opening.applyResult(.{ .ticket = opening.ticket, .phase = .failed, .may_have_created = true }, true);
526 try std.testing.expect(opening.closed and opening.job == null and !opening.inserted);
527 try std.testing.expect(std.mem.indexOf(u8, opening.noticeText(), "outcome unknown for '3'") != null);
528
529 opening.closed = false;
530 rt.workspace.active_tab_id += 1;
531 try opening.applyResult(.{ .ticket = opening.ticket, .phase = .created, .may_have_created = true }, true);
532 try std.testing.expect(opening.closed and !opening.inserted);
533 try std.testing.expect(std.mem.indexOf(u8, opening.noticeText(), "Session created '3'") != null);
534 rt.workspace.active_tab_id = opening.origin_tab;
535
536 opening.closed = false;
537 _ = rt.workspace.focus(neighbor);
538 try opening.applyResult(.{ .ticket = opening.ticket, .phase = .created, .may_have_created = true }, true);
539 try std.testing.expect(opening.closed and opening.inserted);
540 try std.testing.expectEqual(neighbor, rt.workspace.tab().focus.?);
541 const flat = rt.workspace.layout(1200, 900, metrics);
542 try std.testing.expectEqual(@as(usize, 3), flat.len);
543 const inserted = flat.items()[1];
544 try std.testing.expect(inserted.outer.y >= flat.get(id).?.outer.y + flat.get(id).?.outer.h);
545 try std.testing.expectEqualStrings("3", rt.workspace.pane(inserted.id).?.identity.session);
546 try std.testing.expect(rt.get(inserted.id).?.pump.opts.existing_only);
547 }
test/native_journey.py
Old New
@@ -7,6 +7,7 @@ start from a lean fixture instead of each re-proving the same window setup.
7 """ 7 """
8 import os 8 import os
9 import shlex 9 import shlex
10 import socket
10 import subprocess 11 import subprocess
11 import sys 12 import sys
12 import time 13 import time
@@ -137,6 +138,55 @@ def flood_while_responsive(rig, refs):
137 rig.ok(f'local flood leaves QUIC pane responsive ({latency * 1000:.0f} ms, {p99} us frame p99)') 138 rig.ok(f'local flood leaves QUIC pane responsive ({latency * 1000:.0f} ms, {p99} us frame p99)')
138 139
139 140
141 def quick_panes(rig, refs):
142 left, right, lower = list(refs)
143 # Returning to the remote after a local split must inherit the remote again;
144 # detached automatic sessions remain alive and must not be joined by accident.
145 for origin, direction in ((right, 'stacked'), (lower, 'beside'), (right, 'stacked')):
146 rig.focus(origin)
147 before = rig.state()
148 sock, _ = refs[origin]
149 names = sessions(sock)
150 other_sock = refs[left if origin == right else right][0]
151 other_names = sessions(other_sock)
152 rig.split(direction)
153 state = rig.wait_state(lambda s: len(s['panes']) == 4 and
154 all(p['phase'] == 'attached' for p in s['panes']))
155 require(state['picker'] is None and not state['command_mode'],
156 'quick split required a picker or stayed in command mode')
157 pane = by_id(state)[state['focus']]
158 require(pane['id'] not in refs, 'quick split did not focus its new pane')
159 name = pane['label'].rsplit('#', 1)[1]
160 require(name.isdecimal() and name not in names and sessions(sock) == names | {name},
161 'quick split did not create a fresh numeric session on the focused daemon')
162 require(sessions(other_sock) == other_names, 'quick split created on the wrong daemon')
163 source = by_id(state)[origin]
164 require(pane['label'].rsplit('#', 1)[0] == source['label'].rsplit('#', 1)[0],
165 'quick split lost its exact connection target')
166 axis, extent = ('y', 'h') if direction == 'stacked' else ('x', 'w')
167 require(pane['outer'][axis] >= source['outer'][axis] + source['outer'][extent],
168 'quick split inserted on the wrong side')
169 rig.mark(pane['id'], sock, name, 'QUICK-' + str(pane['id']))
170 rig.chord('d')
171 state = rig.wait_state(lambda s: len(s['panes']) == 3)
172 require(stable_layout(state) == stable_layout(before), 'detaching quick pane changed existing layout')
173 require(name in sessions(sock), 'detaching a quick pane ended its session')
174
175 # A split that cannot fit must fail before creating a remote shell.
176 rig.focus(right)
177 names = sessions(refs[right][0])
178 rig.send('resize:8x8')
179 rig.wait_state(lambda s: (s['width'], s['height']) == (8, 8))
180 rig.split('stacked')
181 state = rig.wait_state(lambda s: bool(s['notice']))
182 require(state['picker'] is None and len(state['panes']) == 3 and state['pending'] is None,
183 'undersized quick split changed the workspace')
184 require(sessions(refs[right][0]) == names, 'undersized quick split created a session')
185 rig.send('resize:960x600')
186 rig.wait_state(lambda s: (s['width'], s['height']) == (960, 600))
187 rig.ok('quick splits inherit local/QUIC targets, auto-name fresh sessions, and reject undersized panes')
188
189
140 def raw_snapshot_and_terminal_peer(rig, refs): 190 def raw_snapshot_and_terminal_peer(rig, refs):
141 left = next(iter(refs)) 191 left = next(iter(refs))
142 sock, name = refs[left] 192 sock, name = refs[left]
@@ -173,7 +223,23 @@ def independent_failures(rig, refs):
173 if sock == refs[right][0]) 223 if sock == refs[right][0])
174 rig.stop_daemon(remote_sock, remote_proc) 224 rig.stop_daemon(remote_sock, remote_proc)
175 rig.wait_state(lambda s: by_id(s)[right]['phase'] != 'attached') 225 rig.wait_state(lambda s: by_id(s)[right]['phase'] != 'attached')
176 rig.mark(left, *refs[left], 'JOURNEY-SURVIVOR') 226 # Keep the fixture port bound but silent: an unbound UDP port can fail
227 # immediately via ICMP, which would never exercise pending cancellation.
228 port = int(rig.targets[remote_sock].rsplit(':', 1)[1])
229 with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as silent:
230 silent.bind(('127.0.0.1', port))
231 rig.focus(right)
232 rig.split('stacked')
233 pending = rig.wait_state(lambda s: s['opening'])
234 require(pending['picker'] is None and 'Opening on' in pending['notice'],
235 'pending remote opening hid the terminal behind a picker')
236 rig.mark(left, *refs[left], 'JOURNEY-SURVIVOR')
237 require(rig.state()['opening'], 'remote opening ended before the cancellation check')
238 started = time.monotonic()
239 rig.key('escape')
240 cancelled = rig.wait_state(lambda s: not s['opening'])
241 require(time.monotonic() - started < 1 and len(cancelled['panes']) == 3 and
242 cancelled['pending'] is None, 'cancelling the remote opening blocked or changed panes')
177 243
178 rig.focus(lower) 244 rig.focus(lower)
179 rig.shell('exit 7') 245 rig.shell('exit 7')
@@ -183,7 +249,7 @@ def independent_failures(rig, refs):
183 require(rig.gui.poll() is None, 'a failed transport or exited neighbour closed the window') 249 require(rig.gui.poll() is None, 'a failed transport or exited neighbour closed the window')
184 rig.quit() 250 rig.quit()
185 require('left' in sessions(refs[left][0]), 'final close ended the surviving local session') 251 require('left' in sessions(refs[left][0]), 'final close ended the surviving local session')
186 rig.ok('remote loss and shell exit stay confined to their panes') 252 rig.ok('remote opening stays responsive and cancels promptly; transport loss and shell exit stay confined')
187 253
188 missing = str(rig.root / 'missing.sock') 254 missing = str(rig.root / 'missing.sock')
189 rig.launch_gui(['--sock', missing, '--session', 'offline'], 'gui-journey-unavailable', attached=False) 255 rig.launch_gui(['--sock', missing, '--session', 'offline'], 'gui-journey-unavailable', attached=False)
@@ -203,6 +269,7 @@ def main():
203 try: 269 try:
204 release_binary(rig) 270 release_binary(rig)
205 refs = basic_workspace(rig) 271 refs = basic_workspace(rig)
272 quick_panes(rig, refs)
206 state = rig.state() 273 state = rig.state()
207 identities = [(p['id'], p['generation']) for p in state['panes']] 274 identities = [(p['id'], p['generation']) for p in state['panes']]
208 drag_and_keys(rig, identities, refs) 275 drag_and_keys(rig, identities, refs)
test/native_lifecycle.py
Old New
@@ -182,7 +182,7 @@ def start_workspace(rig, *, quic=False, picker_checks=False):
182 rig.new_session('left') 182 rig.new_session('left')
183 rig.wait_state(lambda s: len(s['panes']) == 1 and s['panes'][0]['phase'] == 'attached') 183 rig.wait_state(lambda s: len(s['panes']) == 1 and s['panes'][0]['phase'] == 'attached')
184 if picker_checks: 184 if picker_checks:
185 rig.chord('b') 185 rig.open_picker('beside')
186 armed = rig.picker('hosts') 186 armed = rig.picker('hosts')
187 require(armed['pending']['direction'] == 'beside', 187 require(armed['pending']['direction'] == 'beside',
188 'b did not arm a split beside the focused pane') 188 'b did not arm a split beside the focused pane')
@@ -201,7 +201,7 @@ def start_workspace(rig, *, quic=False, picker_checks=False):
201 rig.new_session('right') 201 rig.new_session('right')
202 rig.wait_state(lambda s: len(s['panes']) == 2 and all(p['phase'] == 'attached' for p in s['panes'])) 202 rig.wait_state(lambda s: len(s['panes']) == 2 and all(p['phase'] == 'attached' for p in s['panes']))
203 if picker_checks: 203 if picker_checks:
204 rig.chord('v') 204 rig.open_picker('stacked')
205 armed = rig.picker('hosts') 205 armed = rig.picker('hosts')
206 require(armed['pending']['direction'] == 'stacked', 206 require(armed['pending']['direction'] == 'stacked',
207 'v did not arm a split below the focused pane') 207 'v did not arm a split below the focused pane')
test/native_picker.py
Old New
@@ -221,7 +221,7 @@ def add_host(rig):
221 rig.catalogue([first_target]) 221 rig.catalogue([first_target])
222 rig.launch_gui(["--sock", first, "--session", "left"], "gui-add-host") 222 rig.launch_gui(["--sock", first, "--session", "left"], "gui-add-host")
223 before = rig.pixels() 223 before = rig.pixels()
224 rig.split("beside") 224 rig.open_picker("beside")
225 state = rig.picker("hosts") 225 state = rig.picker("hosts")
226 rect = state["picker"]["rect"] 226 rect = state["picker"]["rect"]
227 painted = rig.pixels() 227 painted = rig.pixels()
@@ -259,7 +259,7 @@ def slow_and_legacy(rig):
259 slow_target, legacy_target = "--sock " + slow.path, "--sock " + legacy.path 259 slow_target, legacy_target = "--sock " + slow.path, "--sock " + legacy.path
260 rig.catalogue([first_target, slow_target, legacy_target]) 260 rig.catalogue([first_target, slow_target, legacy_target])
261 rig.launch_gui(["--sock", first, "--session", "left"], "gui-slow-picker") 261 rig.launch_gui(["--sock", first, "--session", "left"], "gui-slow-picker")
262 rig.split("beside") 262 rig.open_picker("beside")
263 rig.picker("hosts") 263 rig.picker("hosts")
264 rig.choose(slow_target) 264 rig.choose(slow_target)
265 eventually(lambda: any(kind == 0x0c for kind, _ in slow.requests), "slow host was never queried") 265 eventually(lambda: any(kind == 0x0c for kind, _ in slow.requests), "slow host was never queried")
test/native_tiling.py
Old New
@@ -135,6 +135,12 @@ class Rig:
135 require(key is not None, "unknown split direction: " + str(direction)) 135 require(key is not None, "unknown split direction: " + str(direction))
136 self.chord(key) 136 self.chord(key)
137 137
138 def open_picker(self, direction=None):
139 self.chord("enter")
140 self.picker("hosts")
141 if direction:
142 self.key(SPLIT_BINDINGS[direction])
143
138 def shell(self, command): 144 def shell(self, command):
139 self.send("text:" + command, "key:enter") 145 self.send("text:" + command, "key:enter")
140 146
@@ -181,11 +187,7 @@ class Rig:
181 self.key("enter") 187 self.key("enter")
182 188
183 def host(self, spelling, direction=None): 189 def host(self, spelling, direction=None):
184 if direction: 190 self.open_picker(direction)
185 self.split(direction)
186 else:
187 self.chord("enter")
188 self.picker("hosts")
189 self.choose(spelling) 191 self.choose(spelling)
190 self.picker("sessions") 192 self.picker("sessions")
191 193
@@ -308,10 +310,10 @@ def normal_scenario(rig, direction):
308 require(result.returncode == 2, "retired staged-target arguments were accepted") 310 require(result.returncode == 2, "retired staged-target arguments were accepted")
309 rig.ok("retired staged-target flags are rejected; picker supplies insertion targets") 311 rig.ok("retired staged-target flags are rejected; picker supplies insertion targets")
310 before = rig.start_gui(first, second, "gui-" + direction) 312 before = rig.start_gui(first, second, "gui-" + direction)
311 rig.split(direction) 313 rig.open_picker(direction)
312 armed = rig.picker("hosts") 314 armed = rig.picker("hosts")
313 require(armed["pending"]["direction"] == direction, 315 require(armed["pending"]["direction"] == direction,
314 "split chord opened picker with the wrong direction") 316 "picker has the wrong split direction")
315 require(len(armed["panes"]) == 1 and armed["panes"][0]["outer"] == before["panes"][0]["outer"], 317 require(len(armed["panes"]) == 1 and armed["panes"][0]["outer"] == before["panes"][0]["outer"],
316 "arming split changed geometry") 318 "arming split changed geometry")
317 stats = rig.command("d", "stats", "--sock", second).stdout 319 stats = rig.command("d", "stats", "--sock", second).stdout
@@ -328,7 +330,7 @@ def normal_scenario(rig, direction):
328 "closing host picker changed panes or forgot its armed split") 330 "closing host picker changed panes or forgot its armed split")
329 rig.key("escape") 331 rig.key("escape")
330 rig.wait_state(lambda s: s["pending"] is None) 332 rig.wait_state(lambda s: s["pending"] is None)
331 rig.split(direction) 333 rig.open_picker(direction)
332 rig.picker("hosts") 334 rig.picker("hosts")
333 rig.choose(rig.targets.get(second, "--sock " + second)) 335 rig.choose(rig.targets.get(second, "--sock " + second))
334 rig.picker("sessions") 336 rig.picker("sessions")