terminal/tmux: return allocated list of actions

pull/9860/head
Mitchell Hashimoto 2025-12-07 07:15:53 -08:00
parent c1d686534e
commit 3cbc232e31
No known key found for this signature in database
GPG Key ID: 523D5DC389D273BC
1 changed files with 142 additions and 55 deletions

View File

@ -1,5 +1,6 @@
const std = @import("std"); const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const testing = std.testing; const testing = std.testing;
const assert = @import("../../quirks.zig").inlineAssert; const assert = @import("../../quirks.zig").inlineAssert;
const control = @import("control.zig"); const control = @import("control.zig");
@ -42,6 +43,10 @@ pub const Viewer = struct {
/// The windows in the current session. /// The windows in the current session.
windows: std.ArrayList(Window), windows: std.ArrayList(Window),
/// The arena used for the prior action allocated state. This contains
/// the contents for the actions as well as the actions slice itself.
action_arena: ArenaAllocator.State,
pub const Action = union(enum) { pub const Action = union(enum) {
/// Tmux has closed the control mode connection, we should end /// Tmux has closed the control mode connection, we should end
/// our viewer session in some way. /// our viewer session in some way.
@ -61,6 +66,11 @@ pub const Viewer = struct {
windows: []const Window, windows: []const Window,
}; };
pub const Input = union(enum) {
/// Data from tmux was received that needs to be processed.
tmux: control.Notification,
};
pub const Window = struct { pub const Window = struct {
id: usize, id: usize,
width: usize, width: usize,
@ -81,32 +91,49 @@ pub const Viewer = struct {
// set this to a real value. // set this to a real value.
.session_id = 0, .session_id = 0,
.windows = .empty, .windows = .empty,
.action_arena = .{},
}; };
} }
pub fn deinit(self: *Viewer) void { pub fn deinit(self: *Viewer) void {
self.windows.deinit(self.alloc); self.windows.deinit(self.alloc);
self.action_arena.promote(self.alloc).deinit();
} }
/// Send in the next tmux notification we got from the control mode /// Send in an input event (such as a tmux protocol notification,
/// protocol. The return value is any action that needs to be taken /// keyboard input for a pane, etc.) and process it. The returned
/// in reaction to this notification (could be none). /// list is a set of actions to take as a result of the input prior
pub fn next(self: *Viewer, n: control.Notification) ?Action { /// to the next input. This list may be empty.
return switch (self.state) { pub fn next(self: *Viewer, input: Input) Allocator.Error![]const Action {
.startup_block => self.nextStartupBlock(n), return switch (input) {
.startup_session => self.nextStartupSession(n), .tmux => try self.nextTmux(input.tmux),
.defunct => defunct: {
log.info("received notification in defunct state, ignoring", .{});
break :defunct null;
},
// Once we're in the main states, there's a bunch of shared
// logic so we centralize it.
.list_windows => self.nextCommand(n),
}; };
} }
fn nextStartupBlock(self: *Viewer, n: control.Notification) ?Action { fn nextTmux(
self: *Viewer,
n: control.Notification,
) Allocator.Error![]const Action {
return switch (self.state) {
.defunct => defunct: {
log.info("received notification in defunct state, ignoring", .{});
break :defunct &.{};
},
.startup_block => try self.nextStartupBlock(n),
.startup_session => try self.nextStartupSession(n),
.idle => try self.nextIdle(n),
// Once we're in the main states, there's a bunch of shared
// logic so we centralize it.
.list_windows => try self.nextCommand(n),
};
}
fn nextStartupBlock(
self: *Viewer,
n: control.Notification,
) Allocator.Error![]const Action {
assert(self.state == .startup_block); assert(self.state == .startup_block);
switch (n) { switch (n) {
@ -117,7 +144,7 @@ pub const Viewer = struct {
// I don't think this is technically possible (reading the // I don't think this is technically possible (reading the
// tmux source code), but if we see an exit we can semantically // tmux source code), but if we see an exit we can semantically
// handle this without issue. // handle this without issue.
.exit => return self.defunct(), .exit => return try self.defunct(),
// Any begin and end (even error) is fine! Now we wait for // Any begin and end (even error) is fine! Now we wait for
// session-changed to get the initial session ID. session-changed // session-changed to get the initial session ID. session-changed
@ -126,69 +153,88 @@ pub const Viewer = struct {
// queue the notification, then do notificatins. // queue the notification, then do notificatins.
.block_end, .block_err => { .block_end, .block_err => {
self.state = .startup_session; self.state = .startup_session;
return null; return &.{};
}, },
// I don't like catch-all else branches but startup is such // I don't like catch-all else branches but startup is such
// a special case of looking for very specific things that // a special case of looking for very specific things that
// are unlikely to expand. // are unlikely to expand.
else => return null, else => return &.{},
} }
} }
fn nextStartupSession(self: *Viewer, n: control.Notification) ?Action { fn nextStartupSession(
self: *Viewer,
n: control.Notification,
) Allocator.Error![]const Action {
assert(self.state == .startup_session); assert(self.state == .startup_session);
switch (n) { switch (n) {
.enter => unreachable, .enter => unreachable,
.exit => return self.defunct(), .exit => return try self.defunct(),
.session_changed => |info| { .session_changed => |info| {
self.session_id = info.id; self.session_id = info.id;
self.state = .list_windows; self.state = .list_windows;
return .{ .command = std.fmt.comptimePrint( return try self.singleAction(.{ .command = std.fmt.comptimePrint(
"list-windows -F '{s}'\n", "list-windows -F '{s}'\n",
.{comptime Format.list_windows.comptimeFormat()}, .{comptime Format.list_windows.comptimeFormat()},
) }; ) });
}, },
else => return null, else => return &.{},
} }
} }
fn nextCommand(self: *Viewer, n: control.Notification) ?Action { fn nextIdle(
assert(self.state != .startup_block); self: *Viewer,
assert(self.state != .startup_session); n: control.Notification,
assert(self.state != .defunct); ) Allocator.Error![]const Action {
assert(self.state == .idle);
switch (n) { switch (n) {
.enter => unreachable, .enter => unreachable,
.exit => return try self.defunct(),
else => return &.{},
}
}
.exit => return self.defunct(), fn nextCommand(
self: *Viewer,
n: control.Notification,
) Allocator.Error![]const Action {
switch (n) {
.enter => unreachable,
.exit => return try self.defunct(),
inline .block_end, inline .block_end,
.block_err, .block_err,
=> |content, tag| switch (self.state) { => |content, tag| switch (self.state) {
.startup_block, .startup_session, .defunct => unreachable, .startup_block,
.startup_session,
.idle,
.defunct,
=> unreachable,
.list_windows => { .list_windows => {
// Move to defunct on error blocks. // Move to defunct on error blocks.
if (comptime tag == .block_err) return self.defunct(); if (comptime tag == .block_err) return try self.defunct();
return self.receivedListWindows(content) catch self.defunct(); return self.receivedListWindows(content) catch return try self.defunct();
}, },
}, },
// TODO: Use exhaustive matching here, determine if we need // TODO: Use exhaustive matching here, determine if we need
// to handle the other cases. // to handle the other cases.
else => return null, else => return &.{},
} }
} }
fn receivedListWindows( fn receivedListWindows(
self: *Viewer, self: *Viewer,
content: []const u8, content: []const u8,
) !Action { ) ![]const Action {
assert(self.state == .list_windows); assert(self.state == .list_windows);
// This stores our new window state from this list-windows output. // This stores our new window state from this list-windows output.
@ -220,18 +266,46 @@ pub const Viewer = struct {
self.windows.deinit(self.alloc); self.windows.deinit(self.alloc);
self.windows = windows; self.windows = windows;
return .{ .windows = self.windows.items }; // Go into the idle state
self.state = .idle;
// TODO: Diff with prior window state, dispatch capture-pane
// requests to collect all of the screen contents, other terminal
// state, etc.
return try self.singleAction(.{ .windows = self.windows.items });
} }
fn defunct(self: *Viewer) Action { /// Helper to return a single action. The input action must not use
/// any allocated memory from `action_arena` since this will reset
/// the arena.
fn singleAction(
self: *Viewer,
action: Action,
) Allocator.Error![]const Action {
// Make our actual arena
var arena = self.action_arena.promote(self.alloc);
// Need to be careful to update our internal state after
// doing allocations since the arena takes a copy of the state.
defer self.action_arena = arena.state;
// Free everything. We could retain some state here if we wanted
// but I don't think its worth it.
_ = arena.reset(.free_all);
// Make our single action slice.
const alloc = arena.allocator();
return try alloc.dupe(Action, &.{action});
}
fn defunct(self: *Viewer) Allocator.Error![]const Action {
self.state = .defunct; self.state = .defunct;
// In the future we may want to deallocate a bunch of memory return try self.singleAction(.exit);
// when we go defunct.
return .exit;
} }
}; };
const State = enum { const State = union(enum) {
/// We start in this state just after receiving the initial /// We start in this state just after receiving the initial
/// DCS 1000p opening sequence. We wait for an initial /// DCS 1000p opening sequence. We wait for an initial
/// begin/end block that is guaranteed to be sent by tmux for /// begin/end block that is guaranteed to be sent by tmux for
@ -246,8 +320,13 @@ const State = enum {
/// Tmux has closed the control mode connection /// Tmux has closed the control mode connection
defunct, defunct,
/// We're waiting on a list-windows response from tmux. /// We're waiting on a list-windows response from tmux. This will
/// be used to resynchronize our entire window state.
list_windows, list_windows,
/// Idle state, we're not actually doing anything right now except
/// waiting for more events from tmux that may change our behavior.
idle,
}; };
/// Format strings used for commands in our viewer. /// Format strings used for commands in our viewer.
@ -284,8 +363,11 @@ const Format = struct {
test "immediate exit" { test "immediate exit" {
var viewer = Viewer.init(testing.allocator); var viewer = Viewer.init(testing.allocator);
defer viewer.deinit(); defer viewer.deinit();
try testing.expectEqual(.exit, viewer.next(.exit).?); const actions = try viewer.next(.{ .tmux = .exit });
try testing.expect(viewer.next(.exit) == null); try testing.expectEqual(1, actions.len);
try testing.expectEqual(.exit, actions[0]);
const actions2 = try viewer.next(.{ .tmux = .exit });
try testing.expectEqual(0, actions2.len);
} }
test "initial flow" { test "initial flow" {
@ -293,31 +375,36 @@ test "initial flow" {
defer viewer.deinit(); defer viewer.deinit();
// First we receive the initial block end // First we receive the initial block end
try testing.expect(viewer.next(.{ .block_end = "" }) == null); const actions0 = try viewer.next(.{ .tmux = .{ .block_end = "" } });
try testing.expectEqual(0, actions0.len);
// Then we receive session-changed with the initial session // Then we receive session-changed with the initial session
{ {
const action = viewer.next(.{ .session_changed = .{ const actions = try viewer.next(.{ .tmux = .{ .session_changed = .{
.id = 42, .id = 42,
.name = "main", .name = "main",
} }).?; } } });
try testing.expect(action == .command); try testing.expectEqual(1, actions.len);
try testing.expect(std.mem.startsWith(u8, action.command, "list-windows")); try testing.expect(actions[0] == .command);
try testing.expect(std.mem.startsWith(u8, actions[0].command, "list-windows"));
try testing.expectEqual(42, viewer.session_id); try testing.expectEqual(42, viewer.session_id);
// log.warn("{s}", .{action.command});
} }
// Simulate our list-windows command // Simulate our list-windows command
{ {
const action = viewer.next(.{ const actions = try viewer.next(.{ .tmux = .{
.block_end = .block_end =
\\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1]
, ,
}).?; } });
try testing.expect(action == .windows); try testing.expectEqual(1, actions.len);
try testing.expectEqual(1, action.windows.len); try testing.expect(actions[0] == .windows);
try testing.expectEqual(1, actions[0].windows.len);
} }
try testing.expectEqual(.exit, viewer.next(.exit).?); const exit_actions = try viewer.next(.{ .tmux = .exit });
try testing.expect(viewer.next(.exit) == null); try testing.expectEqual(1, exit_actions.len);
try testing.expectEqual(.exit, exit_actions[0]);
const final_actions = try viewer.next(.{ .tmux = .exit });
try testing.expectEqual(0, final_actions.len);
} }