mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-05 23:28:41 +00:00
inspector: add copy and export for terminal IO events (#13519)
Adds "Copy" and "Export to file" buttons to the Terminal IO inspector so recorded VT events can be saved outside the app for sharing or analysis. Found myself needing/wishing for this while I was debugging my tmux fork with libghostty-vt. Disclaimer: I haven't considered performance at all, so please lmk if there are anything here you would like me to optimize.
This commit is contained in:
@@ -196,6 +196,9 @@ pub const Action = union(Key) {
|
||||
/// rendered at the next opportunity.
|
||||
render_inspector,
|
||||
|
||||
/// Export the Terminal IO inspector event log.
|
||||
export_terminal_io: ExportTerminalIO,
|
||||
|
||||
/// Show a desktop notification.
|
||||
desktop_notification: DesktopNotification,
|
||||
|
||||
@@ -381,6 +384,7 @@ pub const Action = union(Key) {
|
||||
inspector,
|
||||
show_gtk_inspector,
|
||||
render_inspector,
|
||||
export_terminal_io,
|
||||
desktop_notification,
|
||||
set_title,
|
||||
set_tab_title,
|
||||
@@ -612,6 +616,37 @@ pub const Inspector = enum(c_int) {
|
||||
}
|
||||
};
|
||||
|
||||
/// Terminal IO inspector contents to export. The contents are only valid for
|
||||
/// the duration of the action callback.
|
||||
pub const ExportTerminalIO = struct {
|
||||
contents: []const u8,
|
||||
|
||||
// Sync with: ghostty_action_export_terminal_io_s
|
||||
pub const C = extern struct {
|
||||
contents: [*]const u8,
|
||||
len: usize,
|
||||
};
|
||||
|
||||
pub fn cval(self: ExportTerminalIO) C {
|
||||
return .{
|
||||
.contents = self.contents.ptr,
|
||||
.len = self.contents.len,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn format(
|
||||
value: @This(),
|
||||
comptime _: []const u8,
|
||||
_: std.fmt.Options,
|
||||
writer: *std.Io.Writer,
|
||||
) !void {
|
||||
try writer.print(
|
||||
"{s}{{ contents: {d} bytes }}",
|
||||
.{ @typeName(@This()), value.contents.len },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
pub const QuitTimer = enum(c_int) {
|
||||
start,
|
||||
stop,
|
||||
|
||||
@@ -703,6 +703,11 @@ pub const Application = extern struct {
|
||||
.initial_size => return Action.initialSize(target, value),
|
||||
|
||||
.inspector => return Action.controlInspector(target, value),
|
||||
.export_terminal_io => return try Action.exportTerminalIO(
|
||||
self,
|
||||
target,
|
||||
value,
|
||||
),
|
||||
|
||||
.key_sequence => return Action.keySequence(target, value),
|
||||
.key_table => return Action.keyTable(target, value),
|
||||
@@ -1997,6 +2002,98 @@ const Action = struct {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn exportTerminalIO(
|
||||
self: *Application,
|
||||
target: apprt.Target,
|
||||
value: apprt.Action.Value(.export_terminal_io),
|
||||
) Allocator.Error!bool {
|
||||
const surface = switch (target) {
|
||||
.app => return false,
|
||||
.surface => |v| v.rt_surface.gobj(),
|
||||
};
|
||||
|
||||
const alloc = self.allocator();
|
||||
const contents = try alloc.dupe(u8, value.contents);
|
||||
errdefer alloc.free(contents);
|
||||
const request = try alloc.create(ExportTerminalIORequest);
|
||||
errdefer alloc.destroy(request);
|
||||
request.* = .{
|
||||
.alloc = alloc,
|
||||
.contents = contents,
|
||||
};
|
||||
|
||||
const parent = ext.getAncestor(gtk.Window, surface.as(gtk.Widget));
|
||||
const dialog = gtk.FileChooserNative.new(
|
||||
i18n._("Export Terminal IO Events"),
|
||||
parent,
|
||||
.save,
|
||||
i18n._("Export"),
|
||||
i18n._("Cancel"),
|
||||
);
|
||||
const chooser = dialog.as(gtk.FileChooser);
|
||||
chooser.setCreateFolders(1);
|
||||
chooser.setCurrentName("ghostty-terminal-io.txt");
|
||||
|
||||
_ = gtk.NativeDialog.signals.response.connect(
|
||||
dialog,
|
||||
*ExportTerminalIORequest,
|
||||
ExportTerminalIORequest.response,
|
||||
request,
|
||||
.{ .destroyData = ExportTerminalIORequest.destroy },
|
||||
);
|
||||
dialog.as(gtk.NativeDialog).show();
|
||||
return true;
|
||||
}
|
||||
|
||||
const ExportTerminalIORequest = struct {
|
||||
alloc: Allocator,
|
||||
contents: []u8,
|
||||
|
||||
fn destroy(self: *ExportTerminalIORequest) callconv(.c) void {
|
||||
self.alloc.free(self.contents);
|
||||
self.alloc.destroy(self);
|
||||
}
|
||||
|
||||
fn response(
|
||||
dialog: *gtk.FileChooserNative,
|
||||
response_id: c_int,
|
||||
self: *ExportTerminalIORequest,
|
||||
) callconv(.c) void {
|
||||
defer dialog.unref();
|
||||
|
||||
if (response_id != @intFromEnum(gtk.ResponseType.accept)) return;
|
||||
|
||||
const file = dialog.as(gtk.FileChooser).getFile() orelse {
|
||||
log.warn("inspector export dialog returned no file", .{});
|
||||
return;
|
||||
};
|
||||
defer file.unref();
|
||||
|
||||
var gerr: ?*glib.Error = null;
|
||||
const replaced = file.replaceContents(
|
||||
self.contents.ptr,
|
||||
self.contents.len,
|
||||
null,
|
||||
0,
|
||||
.{},
|
||||
null,
|
||||
null,
|
||||
&gerr,
|
||||
);
|
||||
if (gerr) |err| {
|
||||
defer err.free();
|
||||
log.err(
|
||||
"failed to export terminal IO events err={s}",
|
||||
.{err.f_message orelse "(unknown)"},
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (replaced == 0) {
|
||||
log.err("failed to export terminal IO events", .{});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pub fn configChange(
|
||||
self: *Application,
|
||||
target: apprt.Target,
|
||||
|
||||
@@ -47,7 +47,7 @@ pub const Inspector = struct {
|
||||
|
||||
pub fn draw(
|
||||
self: *Inspector,
|
||||
surface: *const Surface,
|
||||
surface: *Surface,
|
||||
mouse: Mouse,
|
||||
) void {
|
||||
// Create our dockspace first. If we had to setup our dockspace,
|
||||
@@ -111,7 +111,7 @@ pub const Inspector = struct {
|
||||
defer cimgui.c.ImGui_End();
|
||||
if (open) {
|
||||
self.vt_stream.draw(
|
||||
surface.alloc,
|
||||
surface,
|
||||
&t.colors.palette.current,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ const CircBuf = @import("../../datastruct/main.zig").CircBuf;
|
||||
const Surface = @import("../../Surface.zig");
|
||||
const screen = @import("screen.zig");
|
||||
|
||||
const log = std.log.scoped(.inspector_terminal_io);
|
||||
|
||||
/// VT event stream inspector widget.
|
||||
pub const Stream = struct {
|
||||
events: VTEvent.Ring,
|
||||
@@ -59,9 +61,10 @@ pub const Stream = struct {
|
||||
|
||||
pub fn draw(
|
||||
self: *Stream,
|
||||
alloc: Allocator,
|
||||
surface: *Surface,
|
||||
palette: *const terminal.color.Palette,
|
||||
) void {
|
||||
const alloc = surface.alloc;
|
||||
const events = &self.events;
|
||||
const handler = &self.parser_stream.handler;
|
||||
const popup_filter = "Filter";
|
||||
@@ -93,6 +96,16 @@ pub const Stream = struct {
|
||||
|
||||
handler.current_seq = 1;
|
||||
}
|
||||
|
||||
cimgui.c.ImGui_SameLineEx(0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x);
|
||||
if (cimgui.c.ImGui_Button("Copy")) {
|
||||
self.copyEvents(surface);
|
||||
}
|
||||
|
||||
cimgui.c.ImGui_SameLineEx(0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x);
|
||||
if (cimgui.c.ImGui_Button("Export to file")) {
|
||||
self.exportEvents(surface);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +343,73 @@ pub const Stream = struct {
|
||||
}
|
||||
} // filter popup
|
||||
}
|
||||
|
||||
/// Copy all recorded events to the standard clipboard in chronological
|
||||
/// order.
|
||||
fn copyEvents(
|
||||
self: *const Stream,
|
||||
surface: *Surface,
|
||||
) void {
|
||||
const alloc = surface.alloc;
|
||||
const contents = self.formatEvents(alloc) catch |err| {
|
||||
log.err("failed to format terminal IO events for copy err={}", .{err});
|
||||
return;
|
||||
};
|
||||
defer alloc.free(contents);
|
||||
|
||||
surface.rt_surface.setClipboard(.standard, &.{.{
|
||||
.mime = "text/plain",
|
||||
.data = contents,
|
||||
}}, false) catch |err| {
|
||||
log.err("failed to copy terminal IO events err={}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
/// Ask the application runtime to save all recorded events to a file.
|
||||
fn exportEvents(
|
||||
self: *const Stream,
|
||||
surface: *Surface,
|
||||
) void {
|
||||
const alloc = surface.alloc;
|
||||
const contents = self.formatEvents(alloc) catch |err| {
|
||||
log.err("failed to format terminal IO events for export err={}", .{err});
|
||||
return;
|
||||
};
|
||||
defer alloc.free(contents);
|
||||
|
||||
const handled = surface.rt_app.performAction(
|
||||
.{ .surface = surface },
|
||||
.export_terminal_io,
|
||||
.{ .contents = contents },
|
||||
) catch |err| {
|
||||
log.err("failed to export terminal IO events err={}", .{err});
|
||||
return;
|
||||
};
|
||||
if (!handled) {
|
||||
log.warn("application runtime does not support exporting terminal IO events", .{});
|
||||
}
|
||||
}
|
||||
|
||||
/// Format all recorded events as tab-separated text. Events are exported
|
||||
/// oldest-first even though the inspector displays the newest event first.
|
||||
fn formatEvents(
|
||||
self: *const Stream,
|
||||
alloc: Allocator,
|
||||
) ![:0]u8 {
|
||||
var buf: std.Io.Writer.Allocating = .init(alloc);
|
||||
errdefer buf.deinit();
|
||||
|
||||
try buf.writer.writeAll("Seq\tKind\tDescription\n");
|
||||
var it = self.events.iterator(.forward);
|
||||
while (it.next()) |ev| {
|
||||
try buf.writer.print(
|
||||
"{d}\t{s}\t{s}\n",
|
||||
.{ ev.seq, @tagName(ev.kind), ev.raw_description },
|
||||
);
|
||||
}
|
||||
|
||||
return try buf.toOwnedSliceSentinel(0);
|
||||
}
|
||||
};
|
||||
|
||||
/// Helper function to check keyboard state and determine navigation action.
|
||||
|
||||
Reference in New Issue
Block a user