mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-20 14:25:19 +00:00
The terminal.Stream next/nextSlice functions can now no longer fail. All prior failure modes were fully isolated in the handler `vt` callbacks. As such, vt callbacks are now required to not return an error and handle their own errors somehow. Allowing streams to be fallible before was an incorrect design. It caused problematic scenarios like in `nextSlice` early terminating processing due to handler errors. This should not be possible. There is no safe way to bubble up vt errors through the stream because if nextSlice is called and multiple errors are returned, we can't coalesce them. We could modify that to return a partial result but its just more work for stream that is unnecessary. The handler can do all of this. This work was discovered due to cleanups to prepare for more C APIs. Less errors make C APIs easier to implement! And, it helps clean up our Zig, too.
2284 lines
91 KiB
Zig
2284 lines
91 KiB
Zig
const std = @import("std");
|
|
const Allocator = std.mem.Allocator;
|
|
const ArenaAllocator = std.heap.ArenaAllocator;
|
|
const testing = std.testing;
|
|
const assert = @import("../../quirks.zig").inlineAssert;
|
|
const size = @import("../size.zig");
|
|
const CircBuf = @import("../../datastruct/main.zig").CircBuf;
|
|
const CursorStyle = @import("../cursor.zig").Style;
|
|
const Screen = @import("../Screen.zig");
|
|
const ScreenSet = @import("../ScreenSet.zig");
|
|
const Terminal = @import("../Terminal.zig");
|
|
const Layout = @import("layout.zig").Layout;
|
|
const control = @import("control.zig");
|
|
const output = @import("output.zig");
|
|
|
|
const log = std.log.scoped(.terminal_tmux_viewer);
|
|
|
|
// TODO: A list of TODOs as I think about them.
|
|
// - We need to make startup more robust so session and block can happen
|
|
// out of order.
|
|
// - We need to ignore `output` for panes that aren't yet initialized
|
|
// (until capture-panes are complete).
|
|
// - We should note what the active window pane is on the tmux side;
|
|
// we can use this at least for initial focus.
|
|
|
|
// NOTE: There is some fragility here that can possibly break if tmux
|
|
// changes their implementation. In particular, the order of notifications
|
|
// and assurances about what is sent when are based on reading the tmux
|
|
// source code as of Dec, 2025. These aren't documented as fixed.
|
|
//
|
|
// I've tried not to depend on anything that seems like it'd change
|
|
// in the future. For example, it seems reasonable that command output
|
|
// always comes before session attachment. But, I am noting this here
|
|
// in case something breaks in the future we can consider it. We should
|
|
// be able to easily unit test all variations seen in the real world.
|
|
|
|
/// The initial capacity of the command queue. We dynamically resize
|
|
/// as necessary so the initial value isn't that important, but if we
|
|
/// want to feel good about it we should make it large enough to support
|
|
/// our most realistic use cases without resizing.
|
|
const COMMAND_QUEUE_INITIAL = 8;
|
|
|
|
/// A viewer is a tmux control mode client that attempts to create
|
|
/// a remote view of a tmux session, including providing the ability to send
|
|
/// new input to the session.
|
|
///
|
|
/// This is the primary use case for tmux control mode, but technically
|
|
/// tmux control mode clients can do anything a normal tmux client can do,
|
|
/// so the `control.zig` and other files in this folder are more general
|
|
/// purpose.
|
|
///
|
|
/// This struct helps move through a state machine of connecting to a tmux
|
|
/// session, negotiating capabilities, listing window state, etc.
|
|
///
|
|
/// ## Viewer Lifecycle
|
|
///
|
|
/// The viewer progresses through several states from initial connection
|
|
/// to steady-state operation. Here is the full flow:
|
|
///
|
|
/// ```
|
|
/// ┌─────────────────────────────────────────────┐
|
|
/// │ TMUX CONTROL MODE START │
|
|
/// │ (DCS 1000p received by host) │
|
|
/// └─────────────────┬───────────────────────────┘
|
|
/// │
|
|
/// ▼
|
|
/// ┌─────────────────────────────────────────────┐
|
|
/// │ startup_block │
|
|
/// │ │
|
|
/// │ Wait for initial %begin/%end block from │
|
|
/// │ tmux. This is the response to the initial │
|
|
/// │ command (e.g., "attach -t 0"). │
|
|
/// └─────────────────┬───────────────────────────┘
|
|
/// │ %end / %error
|
|
/// ▼
|
|
/// ┌─────────────────────────────────────────────┐
|
|
/// │ startup_session │
|
|
/// │ │
|
|
/// │ Wait for %session-changed notification │
|
|
/// │ to get the initial session ID. │
|
|
/// └─────────────────┬───────────────────────────┘
|
|
/// │ %session-changed
|
|
/// ▼
|
|
/// ┌─────────────────────────────────────────────┐
|
|
/// │ command_queue │
|
|
/// │ │
|
|
/// │ Main operating state. Process commands │
|
|
/// │ sequentially and handle notifications. │
|
|
/// └─────────────────────────────────────────────┘
|
|
/// │
|
|
/// ┌───────────────────────────┼───────────────────────────┐
|
|
/// │ │ │
|
|
/// ▼ ▼ ▼
|
|
/// ┌──────────────────────────┐ ┌──────────────────────────┐ ┌────────────────────────┐
|
|
/// │ tmux_version │ │ list_windows │ │ %output / %layout- │
|
|
/// │ │ │ │ │ change / etc. │
|
|
/// │ Query tmux version for │ │ Get all windows in the │ │ │
|
|
/// │ compatibility checks. │ │ current session. │ │ Handle live updates │
|
|
/// └──────────────────────────┘ └────────────┬─────────────┘ │ from tmux server. │
|
|
/// │ └────────────────────────┘
|
|
/// ▼
|
|
/// ┌─────────────────────────────────────────────┐
|
|
/// │ syncLayouts │
|
|
/// │ │
|
|
/// │ For each window, parse layout and sync │
|
|
/// │ panes. New panes trigger capture commands. │
|
|
/// └─────────────────┬───────────────────────────┘
|
|
/// │
|
|
/// ┌───────────────────────────┴───────────────────────────┐
|
|
/// │ For each new pane: │
|
|
/// ▼ ▼
|
|
/// ┌──────────────────────────┐ ┌──────────────────────────┐
|
|
/// │ pane_history │ │ pane_visible │
|
|
/// │ (primary screen) │ │ (primary screen) │
|
|
/// │ │ │ │
|
|
/// │ Capture scrollback │ │ Capture visible area │
|
|
/// │ history into terminal. │ │ into terminal. │
|
|
/// └──────────────────────────┘ └──────────────────────────┘
|
|
/// │ │
|
|
/// ▼ ▼
|
|
/// ┌──────────────────────────┐ ┌──────────────────────────┐
|
|
/// │ pane_history │ │ pane_visible │
|
|
/// │ (alternate screen) │ │ (alternate screen) │
|
|
/// └──────────────────────────┘ └──────────────────────────┘
|
|
/// │ │
|
|
/// └───────────────────────────┬───────────────────────────┘
|
|
/// ▼
|
|
/// ┌─────────────────────────────────────────────┐
|
|
/// │ pane_state │
|
|
/// │ │
|
|
/// │ Query cursor position, cursor style, │
|
|
/// │ and alternate screen mode for all panes. │
|
|
/// └─────────────────────────────────────────────┘
|
|
/// │
|
|
/// ▼
|
|
/// ┌─────────────────────────────────────────────┐
|
|
/// │ READY FOR OPERATION │
|
|
/// │ │
|
|
/// │ Panes are populated with content. The │
|
|
/// │ viewer handles %output for live updates, │
|
|
/// │ %layout-change for pane changes, and │
|
|
/// │ %session-changed for session switches. │
|
|
/// └─────────────────────────────────────────────┘
|
|
/// ```
|
|
///
|
|
/// ## Error Handling
|
|
///
|
|
/// At any point, if an unrecoverable error occurs or tmux sends `%exit`,
|
|
/// the viewer transitions to the `defunct` state and emits an `.exit` action.
|
|
///
|
|
/// ## Session Changes
|
|
///
|
|
/// When `%session-changed` is received during `command_queue` state, the
|
|
/// viewer resets itself completely: clears all windows/panes, emits an
|
|
/// empty windows action, and restarts the `list_windows` flow for the new
|
|
/// session.
|
|
///
|
|
pub const Viewer = struct {
|
|
/// Allocator used for all internal state.
|
|
alloc: Allocator,
|
|
|
|
/// Current state of the state machine.
|
|
state: State,
|
|
|
|
/// The current session ID we're attached to.
|
|
session_id: usize,
|
|
|
|
/// The tmux server version string (e.g., "3.5a"). We capture this
|
|
/// on startup because it will allow us to change behavior between
|
|
/// versions as necessary.
|
|
tmux_version: []const u8,
|
|
|
|
/// The list of commands we've sent that we want to send and wait
|
|
/// for a response for. We only send one command at a time just
|
|
/// to avoid any possible confusion around ordering.
|
|
command_queue: CommandQueue,
|
|
|
|
/// The windows in the current session.
|
|
windows: std.ArrayList(Window),
|
|
|
|
/// The panes in the current session, mapped by pane ID.
|
|
panes: PanesMap,
|
|
|
|
/// 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,
|
|
|
|
/// A single action pre-allocated that we use for single-action
|
|
/// returns (common). This ensures that we can never get allocation
|
|
/// errors on single-action returns, especially those such as `.exit`.
|
|
action_single: [1]Action,
|
|
|
|
pub const CommandQueue = CircBuf(Command, undefined);
|
|
pub const PanesMap = std.AutoArrayHashMapUnmanaged(usize, Pane);
|
|
|
|
pub const Action = union(enum) {
|
|
/// Tmux has closed the control mode connection, we should end
|
|
/// our viewer session in some way.
|
|
exit,
|
|
|
|
/// Send a command to tmux, e.g. `list-windows`. The caller
|
|
/// should not worry about parsing this or reading what command
|
|
/// it is; just send it to tmux as-is. This will include the
|
|
/// trailing newline so you can send it directly.
|
|
command: []const u8,
|
|
|
|
/// Windows changed. This may add, remove or change windows. The
|
|
/// caller is responsible for diffing the new window list against
|
|
/// the prior one. Remember that for a given Viewer, window IDs
|
|
/// are guaranteed to be stable. Additionally, tmux (as of Dec 2025)
|
|
/// never reuses window IDs within a server process lifetime.
|
|
windows: []const Window,
|
|
|
|
pub fn format(self: Action, writer: *std.Io.Writer) !void {
|
|
const T = Action;
|
|
const info = @typeInfo(T).@"union";
|
|
|
|
try writer.writeAll(@typeName(T));
|
|
if (info.tag_type) |TagType| {
|
|
try writer.writeAll("{ .");
|
|
try writer.writeAll(@tagName(@as(TagType, self)));
|
|
try writer.writeAll(" = ");
|
|
|
|
inline for (info.fields) |u_field| {
|
|
if (self == @field(TagType, u_field.name)) {
|
|
const value = @field(self, u_field.name);
|
|
switch (u_field.type) {
|
|
[]const u8 => try writer.print("\"{s}\"", .{std.mem.trim(u8, value, " \t\r\n")}),
|
|
else => try writer.print("{any}", .{value}),
|
|
}
|
|
}
|
|
}
|
|
|
|
try writer.writeAll(" }");
|
|
}
|
|
}
|
|
};
|
|
|
|
pub const Input = union(enum) {
|
|
/// Data from tmux was received that needs to be processed.
|
|
tmux: control.Notification,
|
|
};
|
|
|
|
pub const Window = struct {
|
|
id: usize,
|
|
width: usize,
|
|
height: usize,
|
|
layout_arena: ArenaAllocator.State,
|
|
layout: Layout,
|
|
|
|
pub fn deinit(self: *Window, alloc: Allocator) void {
|
|
self.layout_arena.promote(alloc).deinit();
|
|
}
|
|
};
|
|
|
|
pub const Pane = struct {
|
|
terminal: Terminal,
|
|
|
|
pub fn deinit(self: *Pane, alloc: Allocator) void {
|
|
self.terminal.deinit(alloc);
|
|
}
|
|
};
|
|
|
|
/// Initialize a new viewer.
|
|
///
|
|
/// The given allocator is used for all internal state. You must
|
|
/// call deinit when you're done with the viewer to free it.
|
|
pub fn init(alloc: Allocator) Allocator.Error!Viewer {
|
|
// Create our initial command queue
|
|
var command_queue: CommandQueue = try .init(alloc, COMMAND_QUEUE_INITIAL);
|
|
errdefer command_queue.deinit(alloc);
|
|
|
|
return .{
|
|
.alloc = alloc,
|
|
.state = .startup_block,
|
|
// The default value here is meaningless. We don't get started
|
|
// until we receive a session-changed notification which will
|
|
// set this to a real value.
|
|
.session_id = 0,
|
|
.tmux_version = "",
|
|
.command_queue = command_queue,
|
|
.windows = .empty,
|
|
.panes = .empty,
|
|
.action_arena = .{},
|
|
.action_single = undefined,
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *Viewer) void {
|
|
{
|
|
for (self.windows.items) |*window| window.deinit(self.alloc);
|
|
self.windows.deinit(self.alloc);
|
|
}
|
|
{
|
|
var it = self.command_queue.iterator(.forward);
|
|
while (it.next()) |command| command.deinit(self.alloc);
|
|
self.command_queue.deinit(self.alloc);
|
|
}
|
|
{
|
|
var it = self.panes.iterator();
|
|
while (it.next()) |kv| kv.value_ptr.deinit(self.alloc);
|
|
self.panes.deinit(self.alloc);
|
|
}
|
|
if (self.tmux_version.len > 0) {
|
|
self.alloc.free(self.tmux_version);
|
|
}
|
|
self.action_arena.promote(self.alloc).deinit();
|
|
}
|
|
|
|
/// Send in an input event (such as a tmux protocol notification,
|
|
/// keyboard input for a pane, etc.) and process it. The returned
|
|
/// list is a set of actions to take as a result of the input prior
|
|
/// to the next input. This list may be empty.
|
|
pub fn next(self: *Viewer, input: Input) []const Action {
|
|
// Developer note: this function must never return an error. If
|
|
// an error occurs we must go into a defunct state or some other
|
|
// state to gracefully handle it.
|
|
return switch (input) {
|
|
.tmux => self.nextTmux(input.tmux),
|
|
};
|
|
}
|
|
|
|
fn nextTmux(
|
|
self: *Viewer,
|
|
n: control.Notification,
|
|
) []const Action {
|
|
return switch (self.state) {
|
|
.defunct => defunct: {
|
|
log.info("received notification in defunct state, ignoring", .{});
|
|
break :defunct &.{};
|
|
},
|
|
|
|
.startup_block => self.nextStartupBlock(n),
|
|
.startup_session => self.nextStartupSession(n),
|
|
.command_queue => self.nextCommand(n),
|
|
};
|
|
}
|
|
|
|
fn nextStartupBlock(
|
|
self: *Viewer,
|
|
n: control.Notification,
|
|
) []const Action {
|
|
assert(self.state == .startup_block);
|
|
|
|
switch (n) {
|
|
// This is only sent by the DCS parser when we first get
|
|
// DCS 1000p, it should never reach us here.
|
|
.enter => unreachable,
|
|
|
|
// I don't think this is technically possible (reading the
|
|
// tmux source code), but if we see an exit we can semantically
|
|
// handle this without issue.
|
|
.exit => return self.defunct(),
|
|
|
|
// Any begin and end (even error) is fine! Now we wait for
|
|
// session-changed to get the initial session ID. session-changed
|
|
// is guaranteed to come after the initial command output
|
|
// since if the initial command is `attach` tmux will run that,
|
|
// queue the notification, then do notificatins.
|
|
.block_end, .block_err => {
|
|
self.state = .startup_session;
|
|
return &.{};
|
|
},
|
|
|
|
// I don't like catch-all else branches but startup is such
|
|
// a special case of looking for very specific things that
|
|
// are unlikely to expand.
|
|
else => return &.{},
|
|
}
|
|
}
|
|
|
|
fn nextStartupSession(
|
|
self: *Viewer,
|
|
n: control.Notification,
|
|
) []const Action {
|
|
assert(self.state == .startup_session);
|
|
|
|
switch (n) {
|
|
.enter => unreachable,
|
|
|
|
.exit => return self.defunct(),
|
|
|
|
.session_changed => |info| {
|
|
self.session_id = info.id;
|
|
|
|
var arena = self.action_arena.promote(self.alloc);
|
|
defer self.action_arena = arena.state;
|
|
_ = arena.reset(.free_all);
|
|
|
|
return self.enterCommandQueue(
|
|
arena.allocator(),
|
|
&.{ .tmux_version, .list_windows },
|
|
) catch {
|
|
log.warn("failed to queue command, becoming defunct", .{});
|
|
return self.defunct();
|
|
};
|
|
},
|
|
|
|
else => return &.{},
|
|
}
|
|
}
|
|
|
|
fn nextIdle(
|
|
self: *Viewer,
|
|
n: control.Notification,
|
|
) []const Action {
|
|
assert(self.state == .idle);
|
|
|
|
switch (n) {
|
|
.enter => unreachable,
|
|
.exit => return self.defunct(),
|
|
else => return &.{},
|
|
}
|
|
}
|
|
|
|
fn nextCommand(
|
|
self: *Viewer,
|
|
n: control.Notification,
|
|
) []const Action {
|
|
// We have to be in a command queue, but the command queue MAY
|
|
// be empty. If it is empty, then receivedCommandOutput will
|
|
// handle it by ignoring any command output. That's okay!
|
|
assert(self.state == .command_queue);
|
|
|
|
// Clear our prior arena so it is ready to be used for any
|
|
// actions immediately.
|
|
{
|
|
var arena = self.action_arena.promote(self.alloc);
|
|
_ = arena.reset(.free_all);
|
|
self.action_arena = arena.state;
|
|
}
|
|
|
|
// Setup our empty actions list that commands can populate.
|
|
var actions: std.ArrayList(Action) = .empty;
|
|
|
|
// Track whether the in-flight command slot is available. Starts true
|
|
// if queue is empty (no command in flight). Set to true when a command
|
|
// completes (block_end/block_err) or the queue is reset (session_changed).
|
|
var command_consumed = self.command_queue.empty();
|
|
|
|
switch (n) {
|
|
.enter => unreachable,
|
|
.exit => return self.defunct(),
|
|
|
|
inline .block_end,
|
|
.block_err,
|
|
=> |content, tag| {
|
|
self.receivedCommandOutput(
|
|
&actions,
|
|
content,
|
|
tag == .block_err,
|
|
) catch {
|
|
log.warn("failed to process command output, becoming defunct", .{});
|
|
return self.defunct();
|
|
};
|
|
|
|
// Command is consumed since a block end/err is the output
|
|
// from a command.
|
|
command_consumed = true;
|
|
},
|
|
|
|
.output => |out| self.receivedOutput(
|
|
out.pane_id,
|
|
out.data,
|
|
) catch |err| {
|
|
log.warn(
|
|
"failed to process output for pane id={}: {}",
|
|
.{ out.pane_id, err },
|
|
);
|
|
},
|
|
|
|
// Session changed means we switched to a different tmux session.
|
|
// We need to reset our state and start fresh with list-windows.
|
|
// This completely replaces the viewer, so treat it like a fresh start.
|
|
.session_changed => |info| {
|
|
self.sessionChanged(
|
|
&actions,
|
|
info.id,
|
|
) catch {
|
|
log.warn("failed to handle session change, becoming defunct", .{});
|
|
return self.defunct();
|
|
};
|
|
|
|
// Command is consumed because sessionChanged resets
|
|
// our entire viewer.
|
|
command_consumed = true;
|
|
},
|
|
|
|
// Layout changed of a single window.
|
|
.layout_change => |info| self.layoutChanged(
|
|
&actions,
|
|
info.window_id,
|
|
info.layout,
|
|
) catch {
|
|
// Note: in the future, we can probably handle a failure
|
|
// here with a fallback to remove this one window, list
|
|
// windows again, and try again.
|
|
log.warn("failed to handle layout change, becoming defunct", .{});
|
|
return self.defunct();
|
|
},
|
|
|
|
// A window was added to this session.
|
|
.window_add => |info| self.windowAdd(info.id) catch {
|
|
log.warn("failed to handle window add, becoming defunct", .{});
|
|
return self.defunct();
|
|
},
|
|
|
|
// The active pane changed. We don't care about this because
|
|
// we handle our own focus.
|
|
.window_pane_changed => {},
|
|
|
|
// We ignore this one. It means a session was created or
|
|
// destroyed. If it was our own session we will get an exit
|
|
// notification very soon. If it is another session we don't
|
|
// care.
|
|
.sessions_changed => {},
|
|
|
|
// We don't use window names for anything, currently.
|
|
.window_renamed => {},
|
|
|
|
// This is for other clients, which we don't do anything about.
|
|
// For us, we'll get `exit` or `session_changed`, respectively.
|
|
.client_detached,
|
|
.client_session_changed,
|
|
=> {},
|
|
}
|
|
|
|
// After processing commands, we add our next command to
|
|
// execute if we have one. We do this last because command
|
|
// processing may itself queue more commands. We only emit a
|
|
// command if a prior command was consumed (or never existed).
|
|
if (self.state == .command_queue and command_consumed) {
|
|
if (self.command_queue.first()) |next_command| {
|
|
// We should not have any commands, because our nextCommand
|
|
// always queues them.
|
|
if (comptime std.debug.runtime_safety) {
|
|
for (actions.items) |action| {
|
|
if (action == .command) assert(false);
|
|
}
|
|
}
|
|
|
|
var arena = self.action_arena.promote(self.alloc);
|
|
defer self.action_arena = arena.state;
|
|
const arena_alloc = arena.allocator();
|
|
|
|
var builder: std.Io.Writer.Allocating = .init(arena_alloc);
|
|
next_command.formatCommand(&builder.writer) catch
|
|
return self.defunct();
|
|
actions.append(
|
|
arena_alloc,
|
|
.{ .command = builder.writer.buffered() },
|
|
) catch return self.defunct();
|
|
}
|
|
}
|
|
|
|
return actions.items;
|
|
}
|
|
|
|
/// When the layout changes for a single window, a pane may be added
|
|
/// or removed that we've never seen, in addition to the layout itself
|
|
/// physically changing.
|
|
///
|
|
/// To handle this, its similar to list-windows except we expect the
|
|
/// window to already exist. We update the layout, do the initLayout
|
|
/// call for any diffs, setup commands to capture any new panes,
|
|
/// prune any removed panes.
|
|
fn layoutChanged(
|
|
self: *Viewer,
|
|
actions: *std.ArrayList(Action),
|
|
window_id: usize,
|
|
layout_str: []const u8,
|
|
) !void {
|
|
// Find the window this layout change is for.
|
|
const window: *Window = window: for (self.windows.items) |*w| {
|
|
if (w.id == window_id) break :window w;
|
|
} else {
|
|
log.info("layout change for unknown window id={}", .{window_id});
|
|
return;
|
|
};
|
|
|
|
// Clear our prior window arena and setup our layout
|
|
window.layout = layout: {
|
|
var arena = window.layout_arena.promote(self.alloc);
|
|
defer window.layout_arena = arena.state;
|
|
_ = arena.reset(.retain_capacity);
|
|
break :layout Layout.parseWithChecksum(
|
|
arena.allocator(),
|
|
layout_str,
|
|
) catch |err| {
|
|
log.info(
|
|
"failed to parse window layout id={} layout={s}",
|
|
.{ window_id, layout_str },
|
|
);
|
|
return err;
|
|
};
|
|
};
|
|
|
|
// Reset our arena so we can build up actions.
|
|
var arena = self.action_arena.promote(self.alloc);
|
|
defer self.action_arena = arena.state;
|
|
const arena_alloc = arena.allocator();
|
|
|
|
// Our initial action is to definitely let the caller know that
|
|
// some windows changed.
|
|
try actions.append(arena_alloc, .{ .windows = self.windows.items });
|
|
|
|
// Sync up our panes
|
|
try self.syncLayouts(self.windows.items);
|
|
}
|
|
|
|
/// When a window is added to the session, we need to refresh our window
|
|
/// list to get the new window's information.
|
|
fn windowAdd(
|
|
self: *Viewer,
|
|
window_id: usize,
|
|
) !void {
|
|
_ = window_id; // We refresh all windows via list-windows
|
|
|
|
// Queue list-windows to get the updated window list
|
|
try self.queueCommands(&.{.list_windows});
|
|
}
|
|
|
|
fn syncLayouts(
|
|
self: *Viewer,
|
|
windows: []const Window,
|
|
) !void {
|
|
// Go through the window layout and setup all our panes. We move
|
|
// this into a new panes map so that we can easily prune our old
|
|
// list.
|
|
var panes: PanesMap = .empty;
|
|
errdefer {
|
|
// Clear out all the new panes.
|
|
var panes_it = panes.iterator();
|
|
while (panes_it.next()) |kv| {
|
|
if (!self.panes.contains(kv.key_ptr.*)) {
|
|
kv.value_ptr.deinit(self.alloc);
|
|
}
|
|
}
|
|
panes.deinit(self.alloc);
|
|
}
|
|
for (windows) |window| try initLayout(
|
|
self.alloc,
|
|
&self.panes,
|
|
&panes,
|
|
window.layout,
|
|
);
|
|
|
|
// Build up the list of removed panes.
|
|
var removed: std.ArrayList(usize) = removed: {
|
|
var removed: std.ArrayList(usize) = .empty;
|
|
errdefer removed.deinit(self.alloc);
|
|
var panes_it = self.panes.iterator();
|
|
while (panes_it.next()) |kv| {
|
|
if (panes.contains(kv.key_ptr.*)) continue;
|
|
try removed.append(self.alloc, kv.key_ptr.*);
|
|
}
|
|
|
|
break :removed removed;
|
|
};
|
|
defer removed.deinit(self.alloc);
|
|
|
|
// Ensure we can add the windows
|
|
try self.windows.ensureTotalCapacity(self.alloc, windows.len);
|
|
|
|
// Get our list of added panes and setup our command queue
|
|
// to populate them.
|
|
// TODO: errdefer cleanup
|
|
{
|
|
var panes_it = panes.iterator();
|
|
var added: bool = false;
|
|
while (panes_it.next()) |kv| {
|
|
const pane_id: usize = kv.key_ptr.*;
|
|
if (self.panes.contains(pane_id)) continue;
|
|
added = true;
|
|
try self.queueCommands(&.{
|
|
.{ .pane_history = .{ .id = pane_id, .screen_key = .primary } },
|
|
.{ .pane_visible = .{ .id = pane_id, .screen_key = .primary } },
|
|
.{ .pane_history = .{ .id = pane_id, .screen_key = .alternate } },
|
|
.{ .pane_visible = .{ .id = pane_id, .screen_key = .alternate } },
|
|
});
|
|
}
|
|
|
|
// If we added any panes, then we also want to resync the pane
|
|
// state (terminal modes and cursor positions and so on).
|
|
if (added) try self.queueCommands(&.{.pane_state});
|
|
}
|
|
|
|
// No more errors after this point. We're about to replace all
|
|
// our owned state with our temporary state, and our errdefers
|
|
// above will double-free if there is an error.
|
|
errdefer comptime unreachable;
|
|
|
|
// Replace our window list if it changed. We assume it didn't
|
|
// change if our pointer is pointing to the same data.
|
|
if (windows.ptr != self.windows.items.ptr) {
|
|
for (self.windows.items) |*window| window.deinit(self.alloc);
|
|
self.windows.clearRetainingCapacity();
|
|
self.windows.appendSliceAssumeCapacity(windows);
|
|
}
|
|
|
|
// Replace our panes
|
|
{
|
|
// First remove our old panes
|
|
for (removed.items) |id| if (self.panes.fetchSwapRemove(
|
|
id,
|
|
)) |entry_const| {
|
|
var entry = entry_const;
|
|
entry.value.deinit(self.alloc);
|
|
};
|
|
// We can now deinit self.panes because the existing
|
|
// entries are preserved.
|
|
self.panes.deinit(self.alloc);
|
|
self.panes = panes;
|
|
}
|
|
}
|
|
|
|
/// When a session changes, we have to basically reset our whole state.
|
|
/// To do this, we emit an empty windows event (so callers can clear all
|
|
/// windows), reset ourself, and start all over.
|
|
fn sessionChanged(
|
|
self: *Viewer,
|
|
actions: *std.ArrayList(Action),
|
|
session_id: usize,
|
|
) (Allocator.Error || std.Io.Writer.Error)!void {
|
|
// Build up a new viewer. Its the easiest way to reset ourselves.
|
|
var replacement: Viewer = try .init(self.alloc);
|
|
errdefer replacement.deinit();
|
|
|
|
// Our actions must start out empty so we don't mix arenas
|
|
assert(actions.items.len == 0);
|
|
errdefer actions.* = .empty;
|
|
|
|
// Build actions: empty windows notification + list-windows command
|
|
var arena = replacement.action_arena.promote(replacement.alloc);
|
|
const arena_alloc = arena.allocator();
|
|
try actions.append(arena_alloc, .{ .windows = &.{} });
|
|
|
|
// Setup our command queue and put ourselves in the command queue
|
|
// state.
|
|
try replacement.queueCommands(&.{.list_windows});
|
|
replacement.state = .command_queue;
|
|
|
|
// Transfer preserved version to replacement
|
|
replacement.tmux_version = try replacement.alloc.dupe(u8, self.tmux_version);
|
|
|
|
// Save arena state back before swap
|
|
replacement.action_arena = arena.state;
|
|
|
|
// Swap our self, no more error handling after this.
|
|
errdefer comptime unreachable;
|
|
self.deinit();
|
|
self.* = replacement;
|
|
|
|
// Set our session ID and jump directly to the list
|
|
self.session_id = session_id;
|
|
|
|
assert(self.state == .command_queue);
|
|
}
|
|
|
|
fn receivedCommandOutput(
|
|
self: *Viewer,
|
|
actions: *std.ArrayList(Action),
|
|
content: []const u8,
|
|
is_err: bool,
|
|
) !void {
|
|
// Get the command we're expecting output for. We need to get the
|
|
// non-pointer value because we are deleting it from the circular
|
|
// buffer immediately. This shallow copy is all we need since
|
|
// all the memory in Command is owned by GPA.
|
|
const command: Command = if (self.command_queue.first()) |ptr| switch (ptr.*) {
|
|
// I truly can't explain this. A simple `ptr.*` copy will cause
|
|
// our memory to become undefined when deleteOldest is called
|
|
// below. I logged all the pointers and they don't match so I
|
|
// don't know how its being set to undefined. But a copy like
|
|
// this does work.
|
|
inline else => |v, tag| @unionInit(
|
|
Command,
|
|
@tagName(tag),
|
|
v,
|
|
),
|
|
} else {
|
|
// If we have no pending commands, this is unexpected.
|
|
log.info("unexpected block output err={}", .{is_err});
|
|
return;
|
|
};
|
|
self.command_queue.deleteOldest(1);
|
|
defer command.deinit(self.alloc);
|
|
|
|
// We'll use our arena for the return value here so we can
|
|
// easily accumulate actions.
|
|
var arena = self.action_arena.promote(self.alloc);
|
|
defer self.action_arena = arena.state;
|
|
const arena_alloc = arena.allocator();
|
|
|
|
// Process our command
|
|
switch (command) {
|
|
.user => {},
|
|
|
|
.pane_state => try self.receivedPaneState(content),
|
|
|
|
.list_windows => try self.receivedListWindows(
|
|
arena_alloc,
|
|
actions,
|
|
content,
|
|
),
|
|
|
|
.pane_history => |cap| try self.receivedPaneHistory(
|
|
cap.screen_key,
|
|
cap.id,
|
|
content,
|
|
),
|
|
|
|
.pane_visible => |cap| try self.receivedPaneVisible(
|
|
cap.screen_key,
|
|
cap.id,
|
|
content,
|
|
),
|
|
|
|
.tmux_version => try self.receivedTmuxVersion(content),
|
|
}
|
|
}
|
|
|
|
fn receivedTmuxVersion(
|
|
self: *Viewer,
|
|
content: []const u8,
|
|
) !void {
|
|
const line = std.mem.trim(u8, content, " \t\r\n");
|
|
if (line.len == 0) return;
|
|
|
|
const data = output.parseFormatStruct(
|
|
Format.tmux_version.Struct(),
|
|
line,
|
|
Format.tmux_version.delim,
|
|
) catch |err| {
|
|
log.info("failed to parse tmux version: {s}", .{line});
|
|
return err;
|
|
};
|
|
|
|
if (self.tmux_version.len > 0) {
|
|
self.alloc.free(self.tmux_version);
|
|
}
|
|
self.tmux_version = try self.alloc.dupe(u8, data.version);
|
|
}
|
|
|
|
fn receivedListWindows(
|
|
self: *Viewer,
|
|
arena_alloc: Allocator,
|
|
actions: *std.ArrayList(Action),
|
|
content: []const u8,
|
|
) !void {
|
|
// If there is an error, reset our actions to what it was before.
|
|
errdefer actions.shrinkRetainingCapacity(actions.items.len);
|
|
|
|
// This stores our new window state from this list-windows output.
|
|
var windows: std.ArrayList(Window) = .empty;
|
|
defer windows.deinit(self.alloc);
|
|
|
|
// Parse all our windows
|
|
var it = std.mem.splitScalar(u8, content, '\n');
|
|
while (it.next()) |line_raw| {
|
|
const line = std.mem.trim(u8, line_raw, " \t\r");
|
|
if (line.len == 0) continue;
|
|
const data = output.parseFormatStruct(
|
|
Format.list_windows.Struct(),
|
|
line,
|
|
Format.list_windows.delim,
|
|
) catch |err| {
|
|
log.info("failed to parse list-windows line: {s}", .{line});
|
|
return err;
|
|
};
|
|
|
|
// Parse the layout
|
|
var arena: ArenaAllocator = .init(self.alloc);
|
|
errdefer arena.deinit();
|
|
const window_alloc = arena.allocator();
|
|
const layout: Layout = Layout.parseWithChecksum(
|
|
window_alloc,
|
|
data.window_layout,
|
|
) catch |err| {
|
|
log.info(
|
|
"failed to parse window layout id={} layout={s}",
|
|
.{ data.window_id, data.window_layout },
|
|
);
|
|
return err;
|
|
};
|
|
|
|
try windows.append(self.alloc, .{
|
|
.id = data.window_id,
|
|
.width = data.window_width,
|
|
.height = data.window_height,
|
|
.layout_arena = arena.state,
|
|
.layout = layout,
|
|
});
|
|
}
|
|
|
|
// Setup our windows action so the caller can process GUI
|
|
// window changes.
|
|
try actions.append(arena_alloc, .{ .windows = windows.items });
|
|
|
|
// Sync up our layouts. This will populate unknown panes, prune, etc.
|
|
try self.syncLayouts(windows.items);
|
|
}
|
|
|
|
fn receivedPaneState(
|
|
self: *Viewer,
|
|
content: []const u8,
|
|
) !void {
|
|
var it = std.mem.splitScalar(u8, content, '\n');
|
|
while (it.next()) |line_raw| {
|
|
const line = std.mem.trim(u8, line_raw, " \t\r");
|
|
if (line.len == 0) continue;
|
|
|
|
const data = output.parseFormatStruct(
|
|
Format.list_panes.Struct(),
|
|
line,
|
|
Format.list_panes.delim,
|
|
) catch |err| {
|
|
log.info("failed to parse list-panes line: {s}", .{line});
|
|
return err;
|
|
};
|
|
|
|
// Get the pane for this ID
|
|
const entry = self.panes.getEntry(data.pane_id) orelse {
|
|
log.info("received pane state for untracked pane id={}", .{data.pane_id});
|
|
continue;
|
|
};
|
|
const pane: *Pane = entry.value_ptr;
|
|
const t: *Terminal = &pane.terminal;
|
|
|
|
// Determine which screen to use based on alternate_on
|
|
const screen_key: ScreenSet.Key = if (data.alternate_on) .alternate else .primary;
|
|
|
|
// Set cursor position on the appropriate screen (tmux uses 0-based)
|
|
if (t.screens.get(screen_key)) |screen| {
|
|
cursor: {
|
|
const cursor_x = std.math.cast(
|
|
size.CellCountInt,
|
|
data.cursor_x,
|
|
) orelse break :cursor;
|
|
const cursor_y = std.math.cast(
|
|
size.CellCountInt,
|
|
data.cursor_y,
|
|
) orelse break :cursor;
|
|
if (cursor_x >= screen.pages.cols or
|
|
cursor_y >= screen.pages.rows) break :cursor;
|
|
screen.cursorAbsolute(cursor_x, cursor_y);
|
|
}
|
|
|
|
// Set cursor shape on this screen
|
|
if (data.cursor_shape.len > 0) {
|
|
if (std.mem.eql(u8, data.cursor_shape, "block")) {
|
|
screen.cursor.cursor_style = .block;
|
|
} else if (std.mem.eql(u8, data.cursor_shape, "underline")) {
|
|
screen.cursor.cursor_style = .underline;
|
|
} else if (std.mem.eql(u8, data.cursor_shape, "bar")) {
|
|
screen.cursor.cursor_style = .bar;
|
|
}
|
|
}
|
|
// "default" or unknown: leave as-is
|
|
}
|
|
|
|
// Set alternate screen saved cursor position
|
|
if (t.screens.get(.alternate)) |alt_screen| cursor: {
|
|
const alt_x = std.math.cast(
|
|
size.CellCountInt,
|
|
data.alternate_saved_x,
|
|
) orelse break :cursor;
|
|
const alt_y = std.math.cast(
|
|
size.CellCountInt,
|
|
data.alternate_saved_y,
|
|
) orelse break :cursor;
|
|
|
|
// If our coordinates are outside our screen we ignore it.
|
|
// tmux actually sends MAX_INT for when there isn't a set
|
|
// cursor position, so this isn't theoretical.
|
|
if (alt_x >= alt_screen.pages.cols or
|
|
alt_y >= alt_screen.pages.rows) break :cursor;
|
|
|
|
alt_screen.cursorAbsolute(alt_x, alt_y);
|
|
}
|
|
|
|
// Set cursor visibility
|
|
t.modes.set(.cursor_visible, data.cursor_flag);
|
|
|
|
// Set cursor blinking
|
|
t.modes.set(.cursor_blinking, data.cursor_blinking);
|
|
|
|
// Terminal modes
|
|
t.modes.set(.insert, data.insert_flag);
|
|
t.modes.set(.wraparound, data.wrap_flag);
|
|
t.modes.set(.keypad_keys, data.keypad_flag);
|
|
t.modes.set(.cursor_keys, data.keypad_cursor_flag);
|
|
t.modes.set(.origin, data.origin_flag);
|
|
|
|
// Mouse modes
|
|
t.modes.set(.mouse_event_any, data.mouse_all_flag);
|
|
t.modes.set(.mouse_event_button, data.mouse_any_flag);
|
|
t.modes.set(.mouse_event_normal, data.mouse_button_flag);
|
|
t.modes.set(.mouse_event_x10, data.mouse_standard_flag);
|
|
t.modes.set(.mouse_format_utf8, data.mouse_utf8_flag);
|
|
t.modes.set(.mouse_format_sgr, data.mouse_sgr_flag);
|
|
|
|
// Focus and bracketed paste
|
|
t.modes.set(.focus_event, data.focus_flag);
|
|
t.modes.set(.bracketed_paste, data.bracketed_paste);
|
|
|
|
// Scroll region (tmux uses 0-based values)
|
|
scroll: {
|
|
const scroll_top = std.math.cast(
|
|
size.CellCountInt,
|
|
data.scroll_region_upper,
|
|
) orelse break :scroll;
|
|
const scroll_bottom = std.math.cast(
|
|
size.CellCountInt,
|
|
data.scroll_region_lower,
|
|
) orelse break :scroll;
|
|
t.scrolling_region.top = scroll_top;
|
|
t.scrolling_region.bottom = scroll_bottom;
|
|
}
|
|
|
|
// Tab stops - parse comma-separated list and set
|
|
t.tabstops.reset(0); // Clear all tabstops first
|
|
if (data.pane_tabs.len > 0) {
|
|
var tabs_it = std.mem.splitScalar(u8, data.pane_tabs, ',');
|
|
while (tabs_it.next()) |tab_str| {
|
|
const col = std.fmt.parseInt(usize, tab_str, 10) catch continue;
|
|
const col_cell = std.math.cast(size.CellCountInt, col) orelse continue;
|
|
if (col_cell >= t.cols) continue;
|
|
t.tabstops.set(col_cell);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn receivedPaneHistory(
|
|
self: *Viewer,
|
|
screen_key: ScreenSet.Key,
|
|
id: usize,
|
|
content: []const u8,
|
|
) !void {
|
|
// Get our pane
|
|
const entry = self.panes.getEntry(id) orelse {
|
|
log.info("received pane history for untracked pane id={}", .{id});
|
|
return;
|
|
};
|
|
const pane: *Pane = entry.value_ptr;
|
|
const t: *Terminal = &pane.terminal;
|
|
_ = try t.switchScreen(screen_key);
|
|
const screen: *Screen = t.screens.active;
|
|
|
|
// Get a VT stream from the terminal so we can send data as-is into
|
|
// it. This will populate the active area too so it won't be exactly
|
|
// correct but we'll get the active contents soon.
|
|
var stream = t.vtStream();
|
|
defer stream.deinit();
|
|
stream.nextSlice(content);
|
|
|
|
// Populate the active area to be empty since this is only history.
|
|
// We'll fill it with blanks and move the cursor to the top-left.
|
|
t.carriageReturn();
|
|
for (0..t.rows) |_| try t.index();
|
|
t.setCursorPos(1, 1);
|
|
|
|
// Our active area should be empty
|
|
if (comptime std.debug.runtime_safety) {
|
|
var discarding: std.Io.Writer.Discarding = .init(&.{});
|
|
screen.dumpString(&discarding.writer, .{
|
|
.tl = screen.pages.getTopLeft(.active),
|
|
.unwrap = false,
|
|
}) catch unreachable;
|
|
assert(discarding.count == 0);
|
|
}
|
|
}
|
|
|
|
fn receivedPaneVisible(
|
|
self: *Viewer,
|
|
screen_key: ScreenSet.Key,
|
|
id: usize,
|
|
content: []const u8,
|
|
) !void {
|
|
// Get our pane
|
|
const entry = self.panes.getEntry(id) orelse {
|
|
log.info("received pane visible for untracked pane id={}", .{id});
|
|
return;
|
|
};
|
|
const pane: *Pane = entry.value_ptr;
|
|
const t: *Terminal = &pane.terminal;
|
|
_ = try t.switchScreen(screen_key);
|
|
|
|
// Erase the active area and reset the cursor to the top-left
|
|
// before writing the visible content.
|
|
t.eraseDisplay(.complete, false);
|
|
t.setCursorPos(1, 1);
|
|
|
|
var stream = t.vtStream();
|
|
defer stream.deinit();
|
|
stream.nextSlice(content);
|
|
}
|
|
|
|
fn receivedOutput(
|
|
self: *Viewer,
|
|
id: usize,
|
|
data: []const u8,
|
|
) !void {
|
|
const entry = self.panes.getEntry(id) orelse {
|
|
log.info("received output for untracked pane id={}", .{id});
|
|
return;
|
|
};
|
|
const pane: *Pane = entry.value_ptr;
|
|
const t: *Terminal = &pane.terminal;
|
|
|
|
var stream = t.vtStream();
|
|
defer stream.deinit();
|
|
stream.nextSlice(data);
|
|
}
|
|
|
|
fn initLayout(
|
|
gpa_alloc: Allocator,
|
|
panes_old: *const PanesMap,
|
|
panes_new: *PanesMap,
|
|
layout: Layout,
|
|
) !void {
|
|
switch (layout.content) {
|
|
// Nested layouts, continue going.
|
|
.horizontal, .vertical => |layouts| {
|
|
for (layouts) |l| {
|
|
try initLayout(
|
|
gpa_alloc,
|
|
panes_old,
|
|
panes_new,
|
|
l,
|
|
);
|
|
}
|
|
},
|
|
|
|
// A leaf! Initialize.
|
|
.pane => |id| pane: {
|
|
const gop = try panes_new.getOrPut(gpa_alloc, id);
|
|
if (gop.found_existing) break :pane;
|
|
errdefer _ = panes_new.swapRemove(gop.key_ptr.*);
|
|
|
|
// If we already have this pane, it is already initialized
|
|
// so just copy it over.
|
|
if (panes_old.getEntry(id)) |entry| {
|
|
gop.value_ptr.* = entry.value_ptr.*;
|
|
break :pane;
|
|
}
|
|
|
|
// TODO: We need to gracefully handle overflow of our
|
|
// max cols/width here. In practice we shouldn't hit this
|
|
// so we cast but its not safe.
|
|
var t: Terminal = try .init(gpa_alloc, .{
|
|
.cols = @intCast(layout.width),
|
|
.rows = @intCast(layout.height),
|
|
});
|
|
errdefer t.deinit(gpa_alloc);
|
|
|
|
gop.value_ptr.* = .{
|
|
.terminal = t,
|
|
};
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Enters the command queue state from any other state, queueing
|
|
/// the commands and returning an action to execute the first command.
|
|
fn enterCommandQueue(
|
|
self: *Viewer,
|
|
arena_alloc: Allocator,
|
|
commands: []const Command,
|
|
) Allocator.Error![]const Action {
|
|
assert(self.state != .command_queue);
|
|
assert(commands.len > 0);
|
|
|
|
// Build our command string to send for the action.
|
|
var builder: std.Io.Writer.Allocating = .init(arena_alloc);
|
|
commands[0].formatCommand(&builder.writer) catch return error.OutOfMemory;
|
|
const action: Action = .{ .command = builder.writer.buffered() };
|
|
|
|
// Add our commands
|
|
try self.command_queue.ensureUnusedCapacity(self.alloc, commands.len);
|
|
for (commands) |cmd| self.command_queue.appendAssumeCapacity(cmd);
|
|
|
|
// Move into the command queue state
|
|
self.state = .command_queue;
|
|
|
|
return self.singleAction(action);
|
|
}
|
|
|
|
/// Queue multiple commands to execute. This doesn't add anything
|
|
/// to the actions queue or return actions or anything because the
|
|
/// command_queue state will automatically send the next command when
|
|
/// it receives output.
|
|
fn queueCommands(
|
|
self: *Viewer,
|
|
commands: []const Command,
|
|
) Allocator.Error!void {
|
|
try self.command_queue.ensureUnusedCapacity(
|
|
self.alloc,
|
|
commands.len,
|
|
);
|
|
for (commands) |command| {
|
|
self.command_queue.appendAssumeCapacity(command);
|
|
}
|
|
}
|
|
|
|
/// Helper to return a single action. The input action may use the arena
|
|
/// for allocated memory; this will not touch the arena.
|
|
fn singleAction(self: *Viewer, action: Action) []const Action {
|
|
// Make our single action slice.
|
|
self.action_single[0] = action;
|
|
return &self.action_single;
|
|
}
|
|
|
|
fn defunct(self: *Viewer) []const Action {
|
|
self.state = .defunct;
|
|
return self.singleAction(.exit);
|
|
}
|
|
};
|
|
|
|
const State = enum {
|
|
/// We start in this state just after receiving the initial
|
|
/// DCS 1000p opening sequence. We wait for an initial
|
|
/// begin/end block that is guaranteed to be sent by tmux for
|
|
/// the initial control mode command. (See tmux server-client.c
|
|
/// where control mode starts).
|
|
startup_block,
|
|
|
|
/// After receiving the initial block, we wait for a session-changed
|
|
/// notification to record the initial session ID.
|
|
startup_session,
|
|
|
|
/// Tmux has closed the control mode connection
|
|
defunct,
|
|
|
|
/// We're sitting on the command queue waiting for command output
|
|
/// in the order provided in the `command_queue` field. This field
|
|
/// isn't part of the state because it can be queued at any state.
|
|
///
|
|
/// Precondition: if self.command_queue.len > 0, then the first
|
|
/// command in the queue has already been sent to tmux (via a
|
|
/// `command` Action). The next output is assumed to be the result
|
|
/// of this command.
|
|
///
|
|
/// To satisfy the above, any transitions INTO this state should
|
|
/// send a command Action for the first command in the queue.
|
|
command_queue,
|
|
};
|
|
|
|
const Command = union(enum) {
|
|
/// List all windows so we can sync our window state.
|
|
list_windows,
|
|
|
|
/// Capture history for the given pane ID.
|
|
pane_history: CapturePane,
|
|
|
|
/// Capture visible area for the given pane ID.
|
|
pane_visible: CapturePane,
|
|
|
|
/// Capture the pane terminal state as best we can. The pane ID(s)
|
|
/// are part of the output so we can map it back to our panes.
|
|
pane_state,
|
|
|
|
/// Get the tmux server version.
|
|
tmux_version,
|
|
|
|
/// User command. This is a command provided by the user. Since
|
|
/// this is user provided, we can't be sure what it is.
|
|
user: []const u8,
|
|
|
|
const CapturePane = struct {
|
|
id: usize,
|
|
screen_key: ScreenSet.Key,
|
|
};
|
|
|
|
pub fn deinit(self: Command, alloc: Allocator) void {
|
|
return switch (self) {
|
|
.list_windows,
|
|
.pane_history,
|
|
.pane_visible,
|
|
.pane_state,
|
|
.tmux_version,
|
|
=> {},
|
|
.user => |v| alloc.free(v),
|
|
};
|
|
}
|
|
|
|
/// Format the command into the command that should be executed
|
|
/// by tmux. Trailing newlines are appended so this can be sent as-is
|
|
/// to tmux.
|
|
pub fn formatCommand(
|
|
self: Command,
|
|
writer: *std.Io.Writer,
|
|
) std.Io.Writer.Error!void {
|
|
switch (self) {
|
|
.list_windows => try writer.writeAll(std.fmt.comptimePrint(
|
|
"list-windows -F '{s}'\n",
|
|
.{comptime Format.list_windows.comptimeFormat()},
|
|
)),
|
|
|
|
.pane_history => |cap| try writer.print(
|
|
// -p = output to stdout instead of buffer
|
|
// -e = output escape sequences for SGR
|
|
// -a = capture alternate screen (only valid for alternate)
|
|
// -q = quiet, don't error if alternate screen doesn't exist
|
|
// -S - = start at the top of history ("-")
|
|
// -E -1 = end at the last line of history (1 before the
|
|
// visible area is -1).
|
|
// -t %{d} = target a specific pane ID
|
|
"capture-pane -p -e -q {s}-S - -E -1 -t %{d}\n",
|
|
.{
|
|
if (cap.screen_key == .alternate) "-a " else "",
|
|
cap.id,
|
|
},
|
|
),
|
|
|
|
.pane_visible => |cap| try writer.print(
|
|
// -p = output to stdout instead of buffer
|
|
// -e = output escape sequences for SGR
|
|
// -a = capture alternate screen (only valid for alternate)
|
|
// -q = quiet, don't error if alternate screen doesn't exist
|
|
// -t %{d} = target a specific pane ID
|
|
// (no -S/-E = capture visible area only)
|
|
"capture-pane -p -e -q {s}-t %{d}\n",
|
|
.{
|
|
if (cap.screen_key == .alternate) "-a " else "",
|
|
cap.id,
|
|
},
|
|
),
|
|
|
|
.pane_state => try writer.writeAll(std.fmt.comptimePrint(
|
|
"list-panes -F '{s}'\n",
|
|
.{comptime Format.list_panes.comptimeFormat()},
|
|
)),
|
|
|
|
.tmux_version => try writer.writeAll(std.fmt.comptimePrint(
|
|
"display-message -p '{s}'\n",
|
|
.{comptime Format.tmux_version.comptimeFormat()},
|
|
)),
|
|
|
|
.user => |v| try writer.writeAll(v),
|
|
}
|
|
}
|
|
};
|
|
|
|
/// Format strings used for commands in our viewer.
|
|
const Format = struct {
|
|
/// The variables included in this format, in order.
|
|
vars: []const output.Variable,
|
|
|
|
/// The delimiter to use between variables. This must be a character
|
|
/// guaranteed to not appear in any of the variable outputs.
|
|
delim: u8,
|
|
|
|
const list_panes: Format = .{
|
|
.delim = ';',
|
|
.vars = &.{
|
|
.pane_id,
|
|
// Cursor position & appearance
|
|
.cursor_x,
|
|
.cursor_y,
|
|
.cursor_flag,
|
|
.cursor_shape,
|
|
.cursor_colour,
|
|
.cursor_blinking,
|
|
// Alternate screen
|
|
.alternate_on,
|
|
.alternate_saved_x,
|
|
.alternate_saved_y,
|
|
// Terminal modes
|
|
.insert_flag,
|
|
.wrap_flag,
|
|
.keypad_flag,
|
|
.keypad_cursor_flag,
|
|
.origin_flag,
|
|
// Mouse modes
|
|
.mouse_all_flag,
|
|
.mouse_any_flag,
|
|
.mouse_button_flag,
|
|
.mouse_standard_flag,
|
|
.mouse_utf8_flag,
|
|
.mouse_sgr_flag,
|
|
// Focus & special features
|
|
.focus_flag,
|
|
.bracketed_paste,
|
|
// Scroll region
|
|
.scroll_region_upper,
|
|
.scroll_region_lower,
|
|
// Tab stops
|
|
.pane_tabs,
|
|
},
|
|
};
|
|
|
|
const list_windows: Format = .{
|
|
.delim = ' ',
|
|
.vars = &.{
|
|
.session_id,
|
|
.window_id,
|
|
.window_width,
|
|
.window_height,
|
|
.window_layout,
|
|
},
|
|
};
|
|
|
|
const tmux_version: Format = .{
|
|
.delim = ' ',
|
|
.vars = &.{.version},
|
|
};
|
|
|
|
/// The format string, available at comptime.
|
|
pub fn comptimeFormat(comptime self: Format) []const u8 {
|
|
return output.comptimeFormat(self.vars, self.delim);
|
|
}
|
|
|
|
/// The struct that can contain the parsed output.
|
|
pub fn Struct(comptime self: Format) type {
|
|
return output.FormatStruct(self.vars);
|
|
}
|
|
};
|
|
|
|
const TestStep = struct {
|
|
input: Viewer.Input,
|
|
contains_tags: []const std.meta.Tag(Viewer.Action) = &.{},
|
|
contains_command: []const u8 = "",
|
|
check: ?*const fn (viewer: *Viewer, []const Viewer.Action) anyerror!void = null,
|
|
check_command: ?*const fn (viewer: *Viewer, []const u8) anyerror!void = null,
|
|
|
|
fn run(self: TestStep, viewer: *Viewer) !void {
|
|
const actions = viewer.next(self.input);
|
|
|
|
// Common mistake, forgetting the newline on a command.
|
|
for (actions) |action| {
|
|
if (action == .command) {
|
|
try testing.expect(std.mem.endsWith(u8, action.command, "\n"));
|
|
}
|
|
}
|
|
|
|
for (self.contains_tags) |tag| {
|
|
var found = false;
|
|
for (actions) |action| {
|
|
if (action == tag) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
try testing.expect(found);
|
|
}
|
|
|
|
if (self.contains_command.len > 0) {
|
|
var found = false;
|
|
for (actions) |action| {
|
|
if (action == .command and
|
|
std.mem.startsWith(u8, action.command, self.contains_command))
|
|
{
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
try testing.expect(found);
|
|
}
|
|
|
|
if (self.check) |check_fn| {
|
|
try check_fn(viewer, actions);
|
|
}
|
|
|
|
if (self.check_command) |check_fn| {
|
|
var found = false;
|
|
for (actions) |action| {
|
|
if (action == .command) {
|
|
found = true;
|
|
try check_fn(viewer, action.command);
|
|
}
|
|
}
|
|
try testing.expect(found);
|
|
}
|
|
}
|
|
};
|
|
|
|
/// A helper to run a series of test steps against a viewer and assert
|
|
/// that the expected actions are produced.
|
|
///
|
|
/// I'm generally not a fan of these types of abstracted tests because
|
|
/// it makes diagnosing failures harder, but being able to construct
|
|
/// simulated tmux inputs and verify outputs is going to be extremely
|
|
/// important since the tmux control mode protocol is very complex and
|
|
/// fragile.
|
|
fn testViewer(viewer: *Viewer, steps: []const TestStep) !void {
|
|
for (steps, 0..) |step, i| {
|
|
step.run(viewer) catch |err| {
|
|
log.warn("testViewer step failed i={} step={}", .{ i, step });
|
|
return err;
|
|
};
|
|
}
|
|
}
|
|
|
|
test "immediate exit" {
|
|
var viewer = try Viewer.init(testing.allocator);
|
|
defer viewer.deinit();
|
|
|
|
try testViewer(&viewer, &.{
|
|
.{
|
|
.input = .{ .tmux = .exit },
|
|
.contains_tags = &.{.exit},
|
|
},
|
|
.{
|
|
.input = .{ .tmux = .exit },
|
|
.check = (struct {
|
|
fn check(_: *Viewer, actions: []const Viewer.Action) anyerror!void {
|
|
try testing.expectEqual(0, actions.len);
|
|
}
|
|
}).check,
|
|
},
|
|
});
|
|
}
|
|
|
|
test "session changed resets state" {
|
|
var viewer = try Viewer.init(testing.allocator);
|
|
defer viewer.deinit();
|
|
|
|
try testViewer(&viewer, &.{
|
|
// Initial startup
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
.{
|
|
.input = .{ .tmux = .{ .session_changed = .{
|
|
.id = 1,
|
|
.name = "first",
|
|
} } },
|
|
.contains_command = "display-message",
|
|
},
|
|
// Receive version response, which triggers list-windows
|
|
.{
|
|
.input = .{ .tmux = .{ .block_end = "3.5a" } },
|
|
.contains_command = "list-windows",
|
|
},
|
|
// Receive window layout with two panes (same format as "initial flow" test)
|
|
.{
|
|
.input = .{ .tmux = .{
|
|
.block_end =
|
|
\\$1 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1]
|
|
,
|
|
} },
|
|
.contains_tags = &.{ .windows, .command },
|
|
.check = (struct {
|
|
fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void {
|
|
try testing.expectEqual(1, v.session_id);
|
|
try testing.expectEqual(1, v.windows.items.len);
|
|
try testing.expectEqual(2, v.panes.count());
|
|
try testing.expectEqualStrings("3.5a", v.tmux_version);
|
|
}
|
|
}).check,
|
|
},
|
|
// Now session changes - should reset everything but keep version
|
|
.{
|
|
.input = .{ .tmux = .{ .session_changed = .{
|
|
.id = 2,
|
|
.name = "second",
|
|
} } },
|
|
.contains_tags = &.{ .windows, .command },
|
|
.contains_command = "list-windows",
|
|
.check = (struct {
|
|
fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void {
|
|
// Session ID should be updated
|
|
try testing.expectEqual(2, v.session_id);
|
|
// Windows should be cleared (empty windows action sent)
|
|
var found_empty_windows = false;
|
|
for (actions) |action| {
|
|
if (action == .windows and action.windows.len == 0) {
|
|
found_empty_windows = true;
|
|
}
|
|
}
|
|
try testing.expect(found_empty_windows);
|
|
// Old windows should be cleared
|
|
try testing.expectEqual(0, v.windows.items.len);
|
|
// Old panes should be cleared
|
|
try testing.expectEqual(0, v.panes.count());
|
|
// Version should still be preserved
|
|
try testing.expectEqualStrings("3.5a", v.tmux_version);
|
|
}
|
|
}).check,
|
|
},
|
|
// Receive new window layout for new session (same layout, different session/window)
|
|
// Uses same pane IDs 0,1 - they should be re-created since old panes were cleared
|
|
.{
|
|
.input = .{ .tmux = .{
|
|
.block_end =
|
|
\\$2 @1 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1]
|
|
,
|
|
} },
|
|
.contains_tags = &.{ .windows, .command },
|
|
.check = (struct {
|
|
fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void {
|
|
try testing.expectEqual(2, v.session_id);
|
|
try testing.expectEqual(1, v.windows.items.len);
|
|
try testing.expectEqual(1, v.windows.items[0].id);
|
|
// Panes 0 and 1 should be created (fresh, since old ones were cleared)
|
|
try testing.expectEqual(2, v.panes.count());
|
|
}
|
|
}).check,
|
|
},
|
|
.{
|
|
.input = .{ .tmux = .exit },
|
|
.contains_tags = &.{.exit},
|
|
},
|
|
});
|
|
}
|
|
|
|
test "initial flow" {
|
|
var viewer = try Viewer.init(testing.allocator);
|
|
defer viewer.deinit();
|
|
|
|
try testViewer(&viewer, &.{
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
.{
|
|
.input = .{ .tmux = .{ .session_changed = .{
|
|
.id = 42,
|
|
.name = "main",
|
|
} } },
|
|
.contains_command = "display-message",
|
|
.check = (struct {
|
|
fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void {
|
|
try testing.expectEqual(42, v.session_id);
|
|
}
|
|
}).check,
|
|
},
|
|
// Receive version response, which triggers list-windows
|
|
.{
|
|
.input = .{ .tmux = .{ .block_end = "3.5a" } },
|
|
.contains_command = "list-windows",
|
|
.check = (struct {
|
|
fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void {
|
|
try testing.expectEqualStrings("3.5a", v.tmux_version);
|
|
}
|
|
}).check,
|
|
},
|
|
.{
|
|
.input = .{ .tmux = .{
|
|
.block_end =
|
|
\\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1]
|
|
,
|
|
} },
|
|
.contains_tags = &.{ .windows, .command },
|
|
.contains_command = "capture-pane",
|
|
// pane_history for pane 0 (primary)
|
|
.check_command = (struct {
|
|
fn check(_: *Viewer, command: []const u8) anyerror!void {
|
|
try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0"));
|
|
try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a"));
|
|
}
|
|
}).check,
|
|
},
|
|
.{
|
|
.input = .{ .tmux = .{
|
|
.block_end =
|
|
\\Hello, world!
|
|
,
|
|
} },
|
|
// Moves on to pane_visible for pane 0 (primary)
|
|
.contains_command = "capture-pane",
|
|
.check_command = (struct {
|
|
fn check(_: *Viewer, command: []const u8) anyerror!void {
|
|
try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0"));
|
|
try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a"));
|
|
}
|
|
}).check,
|
|
.check = (struct {
|
|
fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void {
|
|
const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr;
|
|
const screen: *Screen = pane.terminal.screens.active;
|
|
{
|
|
const str = try screen.dumpStringAlloc(
|
|
testing.allocator,
|
|
.{ .history = .{} },
|
|
);
|
|
defer testing.allocator.free(str);
|
|
try testing.expectEqualStrings("Hello, world!", str);
|
|
}
|
|
{
|
|
const str = try screen.dumpStringAlloc(
|
|
testing.allocator,
|
|
.{ .active = .{} },
|
|
);
|
|
defer testing.allocator.free(str);
|
|
try testing.expectEqualStrings("", str);
|
|
}
|
|
}
|
|
}).check,
|
|
},
|
|
.{
|
|
.input = .{ .tmux = .{ .block_end = "" } },
|
|
// Moves on to pane_history for pane 0 (alternate)
|
|
.contains_command = "capture-pane",
|
|
.check_command = (struct {
|
|
fn check(_: *Viewer, command: []const u8) anyerror!void {
|
|
try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0"));
|
|
try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a"));
|
|
}
|
|
}).check,
|
|
},
|
|
.{
|
|
.input = .{ .tmux = .{ .block_end = "" } },
|
|
// Moves on to pane_visible for pane 0 (alternate)
|
|
.contains_command = "capture-pane",
|
|
.check_command = (struct {
|
|
fn check(_: *Viewer, command: []const u8) anyerror!void {
|
|
try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0"));
|
|
try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a"));
|
|
}
|
|
}).check,
|
|
},
|
|
.{
|
|
.input = .{ .tmux = .{ .block_end = "" } },
|
|
// Moves on to pane_history for pane 1 (primary)
|
|
.contains_command = "capture-pane",
|
|
.check_command = (struct {
|
|
fn check(_: *Viewer, command: []const u8) anyerror!void {
|
|
try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1"));
|
|
try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a"));
|
|
}
|
|
}).check,
|
|
},
|
|
.{
|
|
.input = .{ .tmux = .{ .block_end = "" } },
|
|
// Moves on to pane_visible for pane 1 (primary)
|
|
.contains_command = "capture-pane",
|
|
.check_command = (struct {
|
|
fn check(_: *Viewer, command: []const u8) anyerror!void {
|
|
try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1"));
|
|
try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a"));
|
|
}
|
|
}).check,
|
|
},
|
|
.{
|
|
.input = .{ .tmux = .{ .block_end = "" } },
|
|
// Moves on to pane_history for pane 1 (alternate)
|
|
.contains_command = "capture-pane",
|
|
.check_command = (struct {
|
|
fn check(_: *Viewer, command: []const u8) anyerror!void {
|
|
try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1"));
|
|
try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a"));
|
|
}
|
|
}).check,
|
|
},
|
|
.{
|
|
.input = .{ .tmux = .{ .block_end = "" } },
|
|
// Moves on to pane_visible for pane 1 (alternate)
|
|
.contains_command = "capture-pane",
|
|
.check_command = (struct {
|
|
fn check(_: *Viewer, command: []const u8) anyerror!void {
|
|
try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1"));
|
|
try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a"));
|
|
}
|
|
}).check,
|
|
},
|
|
.{
|
|
.input = .{ .tmux = .{ .block_end = "" } },
|
|
},
|
|
.{
|
|
.input = .{ .tmux = .{ .output = .{ .pane_id = 0, .data = "new output" } } },
|
|
.check = (struct {
|
|
fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void {
|
|
try testing.expectEqual(0, actions.len);
|
|
const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr;
|
|
const screen: *Screen = pane.terminal.screens.active;
|
|
const str = try screen.dumpStringAlloc(
|
|
testing.allocator,
|
|
.{ .active = .{} },
|
|
);
|
|
defer testing.allocator.free(str);
|
|
try testing.expect(std.mem.containsAtLeast(u8, str, 1, "new output"));
|
|
}
|
|
}).check,
|
|
},
|
|
.{
|
|
.input = .{ .tmux = .{ .output = .{ .pane_id = 999, .data = "ignored" } } },
|
|
.check = (struct {
|
|
fn check(_: *Viewer, actions: []const Viewer.Action) anyerror!void {
|
|
try testing.expectEqual(0, actions.len);
|
|
}
|
|
}).check,
|
|
},
|
|
.{
|
|
.input = .{ .tmux = .exit },
|
|
.contains_tags = &.{.exit},
|
|
},
|
|
});
|
|
}
|
|
|
|
test "layout change" {
|
|
var viewer = try Viewer.init(testing.allocator);
|
|
defer viewer.deinit();
|
|
|
|
try testViewer(&viewer, &.{
|
|
// Initial startup
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
.{
|
|
.input = .{ .tmux = .{ .session_changed = .{
|
|
.id = 1,
|
|
.name = "test",
|
|
} } },
|
|
.contains_command = "display-message",
|
|
},
|
|
// Receive version response, which triggers list-windows
|
|
.{
|
|
.input = .{ .tmux = .{ .block_end = "3.5a" } },
|
|
.contains_command = "list-windows",
|
|
},
|
|
// Receive initial window layout with one pane
|
|
.{
|
|
.input = .{ .tmux = .{
|
|
.block_end =
|
|
\\$0 @0 83 44 b7dd,83x44,0,0,0
|
|
,
|
|
} },
|
|
.contains_tags = &.{ .windows, .command },
|
|
.check = (struct {
|
|
fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void {
|
|
try testing.expectEqual(1, v.windows.items.len);
|
|
try testing.expectEqual(1, v.panes.count());
|
|
try testing.expect(v.panes.contains(0));
|
|
}
|
|
}).check,
|
|
},
|
|
// Complete all capture-pane commands for pane 0 (primary and alternate)
|
|
// plus pane_state
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
// Now send a layout_change that splits into two panes
|
|
.{
|
|
.input = .{ .tmux = .{ .layout_change = .{
|
|
.window_id = 0,
|
|
.layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]",
|
|
.visible_layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]",
|
|
.raw_flags = "*",
|
|
} } },
|
|
.contains_tags = &.{.windows},
|
|
.check = (struct {
|
|
fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void {
|
|
// Should still have 1 window
|
|
try testing.expectEqual(1, v.windows.items.len);
|
|
// Should now have 2 panes (0 and 2)
|
|
try testing.expectEqual(2, v.panes.count());
|
|
try testing.expect(v.panes.contains(0));
|
|
try testing.expect(v.panes.contains(2));
|
|
// Commands should be queued for the new pane (4 capture-pane + 1 pane_state)
|
|
try testing.expectEqual(5, v.command_queue.len());
|
|
}
|
|
}).check,
|
|
},
|
|
.{
|
|
.input = .{ .tmux = .exit },
|
|
.contains_tags = &.{.exit},
|
|
},
|
|
});
|
|
}
|
|
|
|
test "layout_change does not return command when queue not empty" {
|
|
var viewer = try Viewer.init(testing.allocator);
|
|
defer viewer.deinit();
|
|
|
|
try testViewer(&viewer, &.{
|
|
// Initial startup
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
.{
|
|
.input = .{ .tmux = .{ .session_changed = .{
|
|
.id = 1,
|
|
.name = "test",
|
|
} } },
|
|
.contains_command = "display-message",
|
|
},
|
|
// Receive version response, which triggers list-windows
|
|
.{
|
|
.input = .{ .tmux = .{ .block_end = "3.5a" } },
|
|
.contains_command = "list-windows",
|
|
},
|
|
// Receive initial window layout with one pane
|
|
.{
|
|
.input = .{ .tmux = .{
|
|
.block_end =
|
|
\\$0 @0 83 44 b7dd,83x44,0,0,0
|
|
,
|
|
} },
|
|
.contains_tags = &.{ .windows, .command },
|
|
.check = (struct {
|
|
fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void {
|
|
try testing.expect(!v.command_queue.empty());
|
|
}
|
|
}).check,
|
|
},
|
|
// Do NOT complete capture-pane commands - queue still has commands.
|
|
// Send a layout_change that splits into two panes.
|
|
// This should NOT return a command action since queue was not empty.
|
|
.{
|
|
.input = .{ .tmux = .{ .layout_change = .{
|
|
.window_id = 0,
|
|
.layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]",
|
|
.visible_layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]",
|
|
.raw_flags = "*",
|
|
} } },
|
|
.contains_tags = &.{.windows},
|
|
.check = (struct {
|
|
fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void {
|
|
try testing.expectEqual(2, v.panes.count());
|
|
// Should not contain a command action
|
|
for (actions) |action| {
|
|
try testing.expect(action != .command);
|
|
}
|
|
}
|
|
}).check,
|
|
},
|
|
.{
|
|
.input = .{ .tmux = .exit },
|
|
.contains_tags = &.{.exit},
|
|
},
|
|
});
|
|
}
|
|
|
|
test "layout_change returns command when queue was empty" {
|
|
var viewer = try Viewer.init(testing.allocator);
|
|
defer viewer.deinit();
|
|
|
|
try testViewer(&viewer, &.{
|
|
// Initial startup
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
.{
|
|
.input = .{ .tmux = .{ .session_changed = .{
|
|
.id = 1,
|
|
.name = "test",
|
|
} } },
|
|
.contains_command = "display-message",
|
|
},
|
|
// Receive version response, which triggers list-windows
|
|
.{
|
|
.input = .{ .tmux = .{ .block_end = "3.5a" } },
|
|
.contains_command = "list-windows",
|
|
},
|
|
// Receive initial window layout with one pane
|
|
.{
|
|
.input = .{ .tmux = .{
|
|
.block_end =
|
|
\\$0 @0 83 44 b7dd,83x44,0,0,0
|
|
,
|
|
} },
|
|
.contains_tags = &.{ .windows, .command },
|
|
},
|
|
// Complete all capture-pane commands for pane 0
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
// Queue should now be empty
|
|
.{
|
|
.input = .{ .tmux = .{ .block_end = "" } },
|
|
.check = (struct {
|
|
fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void {
|
|
try testing.expect(v.command_queue.empty());
|
|
}
|
|
}).check,
|
|
},
|
|
// Now send a layout_change that splits into two panes.
|
|
// This should return a command action since we're queuing commands
|
|
// for the new pane and the queue was empty.
|
|
.{
|
|
.input = .{ .tmux = .{ .layout_change = .{
|
|
.window_id = 0,
|
|
.layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]",
|
|
.visible_layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]",
|
|
.raw_flags = "*",
|
|
} } },
|
|
.contains_tags = &.{ .windows, .command },
|
|
.check = (struct {
|
|
fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void {
|
|
try testing.expectEqual(2, v.panes.count());
|
|
try testing.expect(!v.command_queue.empty());
|
|
}
|
|
}).check,
|
|
},
|
|
.{
|
|
.input = .{ .tmux = .exit },
|
|
.contains_tags = &.{.exit},
|
|
},
|
|
});
|
|
}
|
|
|
|
test "window_add queues list_windows when queue empty" {
|
|
var viewer = try Viewer.init(testing.allocator);
|
|
defer viewer.deinit();
|
|
|
|
try testViewer(&viewer, &.{
|
|
// Initial startup
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
.{
|
|
.input = .{ .tmux = .{ .session_changed = .{
|
|
.id = 1,
|
|
.name = "test",
|
|
} } },
|
|
.contains_command = "display-message",
|
|
},
|
|
// Receive version response, which triggers list-windows
|
|
.{
|
|
.input = .{ .tmux = .{ .block_end = "3.5a" } },
|
|
.contains_command = "list-windows",
|
|
},
|
|
// Receive initial window layout with one pane
|
|
.{
|
|
.input = .{ .tmux = .{
|
|
.block_end =
|
|
\\$0 @0 83 44 b7dd,83x44,0,0,0
|
|
,
|
|
} },
|
|
.contains_tags = &.{ .windows, .command },
|
|
},
|
|
// Complete all capture-pane commands for pane 0
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
// Queue should now be empty
|
|
.{
|
|
.input = .{ .tmux = .{ .block_end = "" } },
|
|
.check = (struct {
|
|
fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void {
|
|
try testing.expect(v.command_queue.empty());
|
|
}
|
|
}).check,
|
|
},
|
|
// Now send window_add - should trigger list-windows command
|
|
.{
|
|
.input = .{ .tmux = .{ .window_add = .{ .id = 1 } } },
|
|
.contains_command = "list-windows",
|
|
.check = (struct {
|
|
fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void {
|
|
// Command queue should have list_windows
|
|
try testing.expect(!v.command_queue.empty());
|
|
try testing.expectEqual(1, v.command_queue.len());
|
|
}
|
|
}).check,
|
|
},
|
|
.{
|
|
.input = .{ .tmux = .exit },
|
|
.contains_tags = &.{.exit},
|
|
},
|
|
});
|
|
}
|
|
|
|
test "window_add queues list_windows when queue not empty" {
|
|
var viewer = try Viewer.init(testing.allocator);
|
|
defer viewer.deinit();
|
|
|
|
try testViewer(&viewer, &.{
|
|
// Initial startup
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
.{
|
|
.input = .{ .tmux = .{ .session_changed = .{
|
|
.id = 1,
|
|
.name = "test",
|
|
} } },
|
|
.contains_command = "display-message",
|
|
},
|
|
// Receive version response, which triggers list-windows
|
|
.{
|
|
.input = .{ .tmux = .{ .block_end = "3.5a" } },
|
|
.contains_command = "list-windows",
|
|
},
|
|
// Receive initial window layout with one pane
|
|
.{
|
|
.input = .{ .tmux = .{
|
|
.block_end =
|
|
\\$0 @0 83 44 b7dd,83x44,0,0,0
|
|
,
|
|
} },
|
|
.contains_tags = &.{ .windows, .command },
|
|
.check = (struct {
|
|
fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void {
|
|
// Queue should have capture-pane commands
|
|
try testing.expect(!v.command_queue.empty());
|
|
}
|
|
}).check,
|
|
},
|
|
// Do NOT complete capture-pane commands - queue still has commands.
|
|
// Send window_add - should queue list-windows but NOT return command action
|
|
.{
|
|
.input = .{ .tmux = .{ .window_add = .{ .id = 1 } } },
|
|
.check = (struct {
|
|
fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void {
|
|
// Should not contain a command action since queue was not empty
|
|
for (actions) |action| {
|
|
try testing.expect(action != .command);
|
|
}
|
|
// But list_windows should be in the queue
|
|
try testing.expect(!v.command_queue.empty());
|
|
}
|
|
}).check,
|
|
},
|
|
.{
|
|
.input = .{ .tmux = .exit },
|
|
.contains_tags = &.{.exit},
|
|
},
|
|
});
|
|
}
|
|
|
|
test "two pane flow with pane state" {
|
|
var viewer = try Viewer.init(testing.allocator);
|
|
defer viewer.deinit();
|
|
|
|
try testViewer(&viewer, &.{
|
|
// Initial block_end from attach
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
// Session changed notification
|
|
.{
|
|
.input = .{ .tmux = .{ .session_changed = .{
|
|
.id = 0,
|
|
.name = "0",
|
|
} } },
|
|
.contains_command = "display-message",
|
|
.check = (struct {
|
|
fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void {
|
|
try testing.expectEqual(0, v.session_id);
|
|
}
|
|
}).check,
|
|
},
|
|
// Receive version response, which triggers list-windows
|
|
.{
|
|
.input = .{ .tmux = .{ .block_end = "3.5a" } },
|
|
.contains_command = "list-windows",
|
|
},
|
|
// list-windows output with 2 panes in a vertical split
|
|
.{
|
|
.input = .{ .tmux = .{
|
|
.block_end =
|
|
\\$0 @0 165 79 ca97,165x79,0,0[165x40,0,0,0,165x38,0,41,4]
|
|
,
|
|
} },
|
|
.contains_tags = &.{ .windows, .command },
|
|
.check = (struct {
|
|
fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void {
|
|
try testing.expectEqual(1, v.windows.items.len);
|
|
const window = v.windows.items[0];
|
|
try testing.expectEqual(0, window.id);
|
|
try testing.expectEqual(165, window.width);
|
|
try testing.expectEqual(79, window.height);
|
|
try testing.expectEqual(2, v.panes.count());
|
|
try testing.expect(v.panes.contains(0));
|
|
try testing.expect(v.panes.contains(4));
|
|
}
|
|
}).check,
|
|
},
|
|
// capture-pane pane 0 primary history
|
|
.{
|
|
.input = .{ .tmux = .{
|
|
.block_end =
|
|
\\prompt %
|
|
\\prompt %
|
|
,
|
|
} },
|
|
},
|
|
// capture-pane pane 0 primary visible
|
|
.{
|
|
.input = .{ .tmux = .{
|
|
.block_end =
|
|
\\prompt %
|
|
,
|
|
} },
|
|
.check = (struct {
|
|
fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void {
|
|
const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr;
|
|
const screen: *Screen = pane.terminal.screens.active;
|
|
{
|
|
const str = try screen.dumpStringAlloc(
|
|
testing.allocator,
|
|
.{ .history = .{} },
|
|
);
|
|
defer testing.allocator.free(str);
|
|
// History has 2 lines with "prompt %" (padded to screen width)
|
|
try testing.expect(std.mem.containsAtLeast(u8, str, 2, "prompt %"));
|
|
}
|
|
{
|
|
const str = try screen.dumpStringAlloc(
|
|
testing.allocator,
|
|
.{ .active = .{} },
|
|
);
|
|
defer testing.allocator.free(str);
|
|
try testing.expectEqualStrings("prompt %", str);
|
|
}
|
|
}
|
|
}).check,
|
|
},
|
|
// capture-pane pane 0 alternate history (empty)
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
// capture-pane pane 0 alternate visible (empty)
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
// capture-pane pane 4 primary history
|
|
.{
|
|
.input = .{ .tmux = .{
|
|
.block_end =
|
|
\\prompt %
|
|
,
|
|
} },
|
|
},
|
|
// capture-pane pane 4 primary visible
|
|
.{
|
|
.input = .{ .tmux = .{
|
|
.block_end =
|
|
\\prompt %
|
|
,
|
|
} },
|
|
.check = (struct {
|
|
fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void {
|
|
const pane: *Viewer.Pane = v.panes.getEntry(4).?.value_ptr;
|
|
const screen: *Screen = pane.terminal.screens.active;
|
|
{
|
|
const str = try screen.dumpStringAlloc(
|
|
testing.allocator,
|
|
.{ .history = .{} },
|
|
);
|
|
defer testing.allocator.free(str);
|
|
try testing.expectEqualStrings("prompt %", str);
|
|
}
|
|
{
|
|
const str = try screen.dumpStringAlloc(
|
|
testing.allocator,
|
|
.{ .active = .{} },
|
|
);
|
|
defer testing.allocator.free(str);
|
|
// Active screen starts with "prompt %" at beginning
|
|
try testing.expect(std.mem.startsWith(u8, str, "prompt %"));
|
|
}
|
|
}
|
|
}).check,
|
|
},
|
|
// capture-pane pane 4 alternate history (empty)
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
// capture-pane pane 4 alternate visible (empty)
|
|
.{ .input = .{ .tmux = .{ .block_end = "" } } },
|
|
// list-panes output with terminal state
|
|
.{
|
|
.input = .{ .tmux = .{
|
|
.block_end =
|
|
\\%0;42;0;1;;;;0;4294967295;4294967295;0;1;0;0;0;0;0;0;0;0;0;;;0;39;8,16,24,32,40,48,56,64,72,80,88,96,104,112,120,128,136,144,152,160
|
|
\\%4;10;5;1;;;;0;4294967295;4294967295;0;1;0;0;0;0;0;0;0;0;0;;;0;37;8,16,24,32,40,48,56,64,72,80,88,96,104,112,120,128,136,144,152,160
|
|
,
|
|
} },
|
|
.check = (struct {
|
|
fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void {
|
|
// Pane 0: cursor at (42, 0), cursor visible, wraparound on
|
|
{
|
|
const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr;
|
|
const t: *Terminal = &pane.terminal;
|
|
const screen: *Screen = t.screens.get(.primary).?;
|
|
try testing.expectEqual(42, screen.cursor.x);
|
|
try testing.expectEqual(0, screen.cursor.y);
|
|
try testing.expect(t.modes.get(.cursor_visible));
|
|
try testing.expect(t.modes.get(.wraparound));
|
|
try testing.expect(!t.modes.get(.insert));
|
|
try testing.expect(!t.modes.get(.origin));
|
|
try testing.expect(!t.modes.get(.keypad_keys));
|
|
try testing.expect(!t.modes.get(.cursor_keys));
|
|
}
|
|
// Pane 4: cursor at (10, 5), cursor visible, wraparound on
|
|
{
|
|
const pane: *Viewer.Pane = v.panes.getEntry(4).?.value_ptr;
|
|
const t: *Terminal = &pane.terminal;
|
|
const screen: *Screen = t.screens.get(.primary).?;
|
|
try testing.expectEqual(10, screen.cursor.x);
|
|
try testing.expectEqual(5, screen.cursor.y);
|
|
try testing.expect(t.modes.get(.cursor_visible));
|
|
try testing.expect(t.modes.get(.wraparound));
|
|
try testing.expect(!t.modes.get(.insert));
|
|
try testing.expect(!t.modes.get(.origin));
|
|
try testing.expect(!t.modes.get(.keypad_keys));
|
|
try testing.expect(!t.modes.get(.cursor_keys));
|
|
}
|
|
}
|
|
}).check,
|
|
},
|
|
.{
|
|
.input = .{ .tmux = .exit },
|
|
.contains_tags = &.{.exit},
|
|
},
|
|
});
|
|
}
|