Merge pull request #149 from mitchellh/osc7

OSC 7 (report pwd) and new Window/Tab/Split uses current cwd
This commit is contained in:
Mitchell Hashimoto
2023-05-31 21:16:37 -07:00
committed by GitHub
12 changed files with 229 additions and 11 deletions

View File

@ -93,14 +93,16 @@ Ghostty supports some features that require shell integration. I am aiming
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:

View File

@ -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,

View File

@ -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();

View File

@ -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,
);

View File

@ -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,
);

View File

@ -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,
);

View File

@ -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;
}

View File

@ -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.

View File

@ -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" {

View File

@ -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;

View File

@ -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

View File

@ -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);
}
};