diff --git a/README.md b/README.md index ffa734403..44868a6d0 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ April 2022. To configure Ghostty, you must use a configuration file. GUI-based configuration is on the roadmap but not yet supported. The configuration file must be -placed at `$XDG_CONFIG_HOME/ghostty/config`, which defaults to +placed at `$XDG_CONFIG_HOME/ghostty/config`, which defaults to `~/.config/ghostty/config` if the [XDG environment is not set](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html). The file format is documented below as an example: @@ -90,17 +90,19 @@ Eventually, we'll have a better mecanism for showing errors to the user. ### Shell Integration Ghostty supports some features that require shell integration. I am aiming -to support many of the features that +to support many of the features that [Kitty supports for shell integration](https://sw.kovidgoyal.net/kitty/shell-integration/). -Today, the most important quality-of-life feature is that Ghostty will -not confirm window close if it detects that the cursor is sitting at a prompt -input (i.e. no subprocess is running). - To enable this functionality, I recommend sourcing Kitty's shell integration files directly for your shell configuration when running Ghostty. For example, for fish, [source this file](https://github.com/kovidgoyal/kitty/blob/master/shell-integration/fish/vendor_conf.d/kitty-shell-integration.fish). +The currently support shell integration features in Ghostty: + + * We do not confirm close for windows where the cursor is at a prompt. + * New terminals start in the working directory of the previously focused terminal. + * The cursor at the prompt is turned into a bar. + ## Roadmap and Status The high-level ambitious plan for the project, in order: diff --git a/src/App.zig b/src/App.zig index b39938392..b2893fdd3 100644 --- a/src/App.zig +++ b/src/App.zig @@ -29,6 +29,10 @@ alloc: Allocator, /// The list of surfaces that are currently active. surfaces: SurfaceList, +/// The last focused surface. This surface may not be valid; +/// you must always call hasSurface to validate it. +focused_surface: ?*Surface = null, + /// The mailbox that can be used to send this thread messages. Note /// this is a blocking queue so if it is full you will get errors (or block). mailbox: Mailbox.Queue, @@ -123,6 +127,14 @@ pub fn deleteSurface(self: *App, rt_surface: *apprt.Surface) void { } } +/// The last focused surface. This is only valid while on the main thread +/// before tick is called. +pub fn focusedSurface(self: *const App) ?*Surface { + const surface = self.focused_surface orelse return null; + if (!self.hasSurface(surface)) return null; + return surface; +} + /// Drain the mailbox. fn drainMailbox(self: *App, rt_app: *apprt.App) !void { while (self.mailbox.pop()) |message| { @@ -131,6 +143,7 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void { .reload_config => try self.reloadConfig(rt_app), .new_window => |msg| try self.newWindow(rt_app, msg), .close => |surface| try self.closeSurface(rt_app, surface), + .focus => |surface| try self.focusSurface(rt_app, surface), .quit => try self.setQuit(), .surface_message => |msg| try self.surfaceMessage(msg.surface, msg.message), .redraw_surface => |surface| try self.redrawSurface(rt_app, surface), @@ -153,6 +166,13 @@ fn closeSurface(self: *App, rt_app: *apprt.App, surface: *Surface) !void { surface.close(); } +fn focusSurface(self: *App, rt_app: *apprt.App, surface: *Surface) !void { + _ = rt_app; + + if (!self.hasSurface(surface)) return; + self.focused_surface = surface; +} + fn redrawSurface(self: *App, rt_app: *apprt.App, surface: *apprt.Surface) !void { if (!self.hasSurface(&surface.core_surface)) return; rt_app.redrawSurface(surface); @@ -194,7 +214,7 @@ fn surfaceMessage(self: *App, surface: *Surface, msg: apprt.surface.Message) !vo // Not a problem. } -fn hasSurface(self: *App, surface: *Surface) bool { +fn hasSurface(self: *const App, surface: *const Surface) bool { for (self.surfaces.items) |v| { if (&v.core_surface == surface) return true; } @@ -215,6 +235,11 @@ pub const Message = union(enum) { /// should close. close: *Surface, + /// The last focused surface. The app keeps track of this to + /// enable "inheriting" various configurations from the last + /// surface. + focus: *Surface, + /// Quit quit: void, diff --git a/src/Surface.zig b/src/Surface.zig index 0d621f758..2a5a9f6a9 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -631,6 +631,16 @@ fn changeConfig(self: *Surface, config: *const configpkg.Config) !void { }; } +/// Returns the pwd of the terminal, if any. This is always copied because +/// the pwd can change at any point from termio. If we are calling from the IO +/// thread you should just check the terminal directly. +pub fn pwd(self: *const Surface, alloc: Allocator) !?[]const u8 { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const terminal_pwd = self.io.terminal.getPwd() orelse return null; + return try alloc.dupe(u8, terminal_pwd); +} + /// Returns the x/y coordinate of where the IME (Input Method Editor) /// keyboard should be rendered. pub fn imePoint(self: *const Surface) apprt.IMEPos { @@ -1224,6 +1234,13 @@ pub fn focusCallback(self: *Surface, focused: bool) !void { .focus = focused, }, .{ .forever = {} }); + // Notify our app if we gained focus. + if (focused) { + _ = self.app_mailbox.push(.{ + .focus = self, + }, .{ .forever = {} }); + } + // Schedule render which also drains our mailbox try self.queueRender(); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index b4c8577f3..36e7d3e77 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -161,11 +161,15 @@ pub const Surface = struct { try app.core_app.addSurface(self); errdefer app.core_app.deleteSurface(self); + // Shallow copy the config so that we can modify it. + var config = try apprt.surface.newConfig(app.core_app, app.config); + defer config.deinit(); + // Initialize our surface right away. We're given a view that is // ready to use. try self.core_surface.init( app.core_app.alloc, - app.config, + &config, .{ .rt_app = app, .mailbox = &app.core_app.mailbox }, self, ); diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index d8da82640..822437b50 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -346,10 +346,14 @@ pub const Surface = struct { try app.app.addSurface(self); errdefer app.app.deleteSurface(self); + // Get our new surface config + var config = try apprt.surface.newConfig(app.app, &app.config); + defer config.deinit(); + // Initialize our surface now that we have the stable pointer. try self.core_surface.init( app.app.alloc, - &app.config, + &config, .{ .rt_app = app, .mailbox = &app.app.mailbox }, self, ); diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index 51ca0d910..05e43cee3 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -722,10 +722,14 @@ pub const Surface = struct { try self.app.core_app.addSurface(self); errdefer self.app.core_app.deleteSurface(self); + // Get our new surface config + var config = try apprt.surface.newConfig(self.app.core_app, &self.app.config); + defer config.deinit(); + // Initialize our surface now that we have the stable pointer. try self.core_surface.init( self.app.core_app.alloc, - &self.app.config, + &config, .{ .rt_app = self.app, .mailbox = &self.app.core_app.mailbox }, self, ); diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 859d694d5..67b4247e8 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -61,3 +61,24 @@ pub const Mailbox = struct { }, timeout); } }; + +/// Returns a new config for a surface for the given app that should be +/// used for any new surfaces. The resulting config should be deinitialized +/// after the surface is initialized. +pub fn newConfig(app: *const App, config: *const Config) !Config { + // Create a shallow clone + var copy = config.shallowClone(app.alloc); + + // Our allocator is our config's arena + const alloc = copy._arena.?.allocator(); + + // Get our previously focused surface for some inherited values. + const prev = app.focusedSurface(); + if (prev) |p| { + if (try p.pwd(alloc)) |pwd| { + copy.@"working-directory" = pwd; + } + } + + return copy; +} diff --git a/src/config.zig b/src/config.zig index 6d75e4e11..9a82c59d2 100644 --- a/src/config.zig +++ b/src/config.zig @@ -691,6 +691,21 @@ pub const Config = struct { } } + /// Create a shallow copy of this config. This will share all the memory + /// allocated with the previous config but will have a new arena for + /// any changes or new allocations. The config should have `deinit` + /// called when it is complete. + /// + /// Beware: these shallow clones are not meant for a long lifetime, + /// they are just meant to exist temporarily for the duration of some + /// modifications. It is very important that the original config not + /// be deallocated while shallow clones exist. + pub fn shallowClone(self: *const Config, alloc_gpa: Allocator) Config { + var result = self.*; + result._arena = ArenaAllocator.init(alloc_gpa); + return result; + } + /// Create a copy of this configuration. This is useful as a starting /// point for modifying a configuration since a config can NOT be /// modified once it is in use by an app or surface. diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 9fa814e3e..57f95841f 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -64,6 +64,9 @@ cols: usize, /// The current scrolling region. scrolling_region: ScrollingRegion, +/// The last reported pwd, if any. +pwd: std.ArrayList(u8), + /// The charset state charset: CharsetState = .{}, @@ -169,6 +172,7 @@ pub fn init(alloc: Allocator, cols: usize, rows: usize) !Terminal { .top = 0, .bottom = rows - 1, }, + .pwd = std.ArrayList(u8).init(alloc), }; } @@ -176,6 +180,7 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void { self.tabstops.deinit(alloc); self.screen.deinit(); self.secondary_screen.deinit(); + self.pwd.deinit(); self.* = undefined; } @@ -1501,6 +1506,19 @@ pub fn cursorIsAtPrompt(self: *Terminal) bool { return false; } +/// Set the pwd for the terminal. +pub fn setPwd(self: *Terminal, pwd: []const u8) !void { + self.pwd.clearRetainingCapacity(); + try self.pwd.appendSlice(pwd); +} + +/// Returns the pwd for the terminal, if any. The memory is owned by the +/// Terminal and is not copied. It is safe until a reset or setPwd. +pub fn getPwd(self: *const Terminal) ?[]const u8 { + if (self.pwd.items.len == 0) return null; + return self.pwd.items; +} + /// Full reset pub fn fullReset(self: *Terminal) void { self.primaryScreen(.{ .clear_on_exit = true, .cursor_save = true }); @@ -1514,6 +1532,7 @@ pub fn fullReset(self: *Terminal) void { self.screen.selection = null; self.scrolling_region = .{ .top = 0, .bottom = self.rows - 1 }; self.previous_char = null; + self.pwd.clearRetainingCapacity(); } test "Terminal: input with no control characters" { diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 3063bed40..ccbdd044e 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -72,6 +72,16 @@ pub const Command = union(enum) { kind: u8, data: []const u8, }, + + /// OSC 7. Reports the current working directory of the shell. This is + /// a moderately flawed escape sequence but one that many major terminals + /// support so we also support it. To understand the flaws, read through + /// this terminal-wg issue: https://gitlab.freedesktop.org/terminal-wg/specifications/-/issues/20 + report_pwd: struct { + /// The reported pwd value. This is not checked for validity. It should + /// be a file URL but it is up to the caller to utilize this value. + value: []const u8, + }, }; pub const Parser = struct { @@ -121,6 +131,7 @@ pub const Parser = struct { @"2", @"5", @"52", + @"7", // We're in a semantic prompt OSC command but we aren't sure // what the command is yet, i.e. `133;` @@ -173,6 +184,7 @@ pub const Parser = struct { '1' => self.state = .@"1", '2' => self.state = .@"2", '5' => self.state = .@"5", + '7' => self.state = .@"7", else => self.state = .invalid, }, @@ -228,6 +240,17 @@ pub const Parser = struct { else => self.state = .invalid, }, + .@"7" => switch (c) { + ';' => { + self.command = .{ .report_pwd = .{ .value = "" } }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.report_pwd.value }; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + .@"52" => switch (c) { ';' => { self.command = .{ .clipboard_contents = undefined }; @@ -557,6 +580,30 @@ test "OSC: get/set clipboard" { try testing.expect(std.mem.eql(u8, "?", cmd.clipboard_contents.data)); } +test "OSC: report pwd" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "7;file:///tmp/example"; + for (input) |ch| p.next(ch); + + const cmd = p.end().?; + try testing.expect(cmd == .report_pwd); + try testing.expect(std.mem.eql(u8, "file:///tmp/example", cmd.report_pwd.value)); +} + +test "OSC: report pwd empty" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "7;"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end() == null); +} + test "OSC: longer than buffer" { const testing = std.testing; diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 2f8766c56..f36fa6315 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -495,6 +495,12 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, + .report_pwd => |v| { + if (@hasDecl(T, "reportPwd")) { + try self.handler.reportPwd(v.value); + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + else => if (@hasDecl(T, "oscUnimplemented")) try self.handler.oscUnimplemented(cmd) else diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 294019b83..0f513c0ba 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -614,12 +614,24 @@ const Subprocess = struct { return pty.master; } + // If we can't access the cwd, then don't set any cwd and inherit. + // This is important because our cwd can be set by the shell (OSC 7) + // and we don't want to break new windows. + const cwd: ?[]const u8 = if (self.cwd) |proposed| cwd: { + if (std.fs.accessAbsolute(proposed, .{})) { + break :cwd proposed; + } else |err| { + log.warn("cannot access cwd, ignoring: {}", .{err}); + break :cwd null; + } + } else null; + // Build our subcommand var cmd: Command = .{ .path = self.path, .args = self.args, .env = &self.env, - .cwd = self.cwd, + .cwd = cwd, .stdin = .{ .handle = pty.slave }, .stdout = .{ .handle = pty.slave }, .stderr = .{ .handle = pty.slave }, @@ -1249,4 +1261,46 @@ const StreamHandler = struct { pub fn endOfInput(self: *StreamHandler) !void { self.terminal.markSemanticPrompt(.command); } + + pub fn reportPwd(self: *StreamHandler, url: []const u8) !void { + const uri = std.Uri.parse(url) catch |e| { + log.warn("invalid url in OSC 7: {}", .{e}); + return; + }; + + if (!std.mem.eql(u8, "file", uri.scheme) and + !std.mem.eql(u8, "kitty-shell-cwd", uri.scheme)) + { + log.warn("OSC 7 scheme must be file, got: {s}", .{uri.scheme}); + return; + } + + // OSC 7 is a little sketchy because anyone can send any value from + // any host (such an SSH session). The best practice terminals follow + // is to valid the hostname to be local. + const host_valid = host_valid: { + const host = uri.host orelse break :host_valid false; + + // Empty or localhost is always good + if (host.len == 0 or std.mem.eql(u8, "localhost", host)) { + break :host_valid true; + } + + // Otherwise, it must match our hostname. + var buf: [std.os.HOST_NAME_MAX]u8 = undefined; + const hostname = std.os.gethostname(&buf) catch |err| { + log.warn("failed to get hostname for OSC 7 validation: {}", .{err}); + break :host_valid false; + }; + + break :host_valid std.mem.eql(u8, host, hostname); + }; + if (!host_valid) { + log.warn("OSC 7 host must be local", .{}); + return; + } + + log.debug("terminal pwd: {s}", .{uri.path}); + try self.terminal.setPwd(uri.path); + } };