diff --git a/include/ghostty.h b/include/ghostty.h index 72bbb57a8..e5afb6d1e 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -645,6 +645,12 @@ typedef enum { GHOSTTY_INSPECTOR_HIDE, } ghostty_action_inspector_e; +// apprt.action.ExportTerminalIO.C +typedef struct { + const char* contents; + size_t len; +} ghostty_action_export_terminal_io_s; + // apprt.action.QuitTimer typedef enum { GHOSTTY_QUIT_TIMER_START, @@ -914,6 +920,7 @@ typedef enum { GHOSTTY_ACTION_INSPECTOR, GHOSTTY_ACTION_SHOW_GTK_INSPECTOR, GHOSTTY_ACTION_RENDER_INSPECTOR, + GHOSTTY_ACTION_EXPORT_TERMINAL_IO, GHOSTTY_ACTION_DESKTOP_NOTIFICATION, GHOSTTY_ACTION_SET_TITLE, GHOSTTY_ACTION_SET_TAB_TITLE, @@ -964,6 +971,7 @@ typedef union { ghostty_action_cell_size_s cell_size; ghostty_action_scrollbar_s scrollbar; ghostty_action_inspector_e inspector; + ghostty_action_export_terminal_io_s export_terminal_io; ghostty_action_desktop_notification_s desktop_notification; ghostty_action_set_title_s set_title; ghostty_action_set_title_s set_tab_title; diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 583357077..a791db66c 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -1,4 +1,5 @@ import SwiftUI +import UniformTypeIdentifiers import UserNotifications import GhosttyKit @@ -539,6 +540,9 @@ extension Ghostty { case GHOSTTY_ACTION_RENDER_INSPECTOR: renderInspector(app, target: target) + case GHOSTTY_ACTION_EXPORT_TERMINAL_IO: + return exportTerminalIO(app, target: target, v: action.action.export_terminal_io) + case GHOSTTY_ACTION_DESKTOP_NOTIFICATION: showDesktopNotification(app, target: target, n: action.action.desktop_notification) @@ -1394,6 +1398,41 @@ extension Ghostty { } } + private static func exportTerminalIO( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_export_terminal_io_s + ) -> Bool { + guard target.tag == GHOSTTY_TARGET_SURFACE, + let surface = target.target.surface, + let surfaceView = self.surfaceView(from: surface), + let window = surfaceView.window, + let contents = v.contents + else { return false } + + // The action data is borrowed for the duration of this callback, + // so copy it before presenting the asynchronous save panel. + let data = Data(bytes: contents, count: v.len) + DispatchQueue.main.async { + let panel = NSSavePanel() + panel.allowedContentTypes = [.plainText] + panel.canCreateDirectories = true + panel.nameFieldStringValue = "ghostty-terminal-io.txt" + panel.beginSheetModal(for: window) { response in + guard response == .OK, let url = panel.url else { return } + do { + try data.write(to: url, options: .atomic) + } catch { + Ghostty.logger.error( + "Failed to export terminal IO events: \(error, privacy: .public)" + ) + } + } + } + + return true + } + private static func showDesktopNotification( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 23bff13d7..6db465277 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -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, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index f1fa1d960..311b36362 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -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,99 @@ 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"); + + const native = dialog.as(gtk.NativeDialog); + _ = gtk.NativeDialog.signals.response.connect( + native, + *ExportTerminalIORequest, + ExportTerminalIORequest.response, + request, + .{ .destroyData = ExportTerminalIORequest.destroy }, + ); + native.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.NativeDialog, + 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, diff --git a/src/inspector/widgets/surface.zig b/src/inspector/widgets/surface.zig index 28bd1def0..a23aebf08 100644 --- a/src/inspector/widgets/surface.zig +++ b/src/inspector/widgets/surface.zig @@ -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, ); } diff --git a/src/inspector/widgets/termio.zig b/src/inspector/widgets/termio.zig index 04574f4ce..a45ea0ff8 100644 --- a/src/inspector/widgets/termio.zig +++ b/src/inspector/widgets/termio.zig @@ -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.