mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-06-15 08:03:56 +00:00
lib-vt: add pwd_changed callback for OSC 7/9/1337
Previously the libghostty-vt stream handler dropped .report_pwd as a no-op, so embedders never saw shell-reported cwd changes and the terminal's pwd field was never populated from escape sequences. Wire the action to setPwd and expose a pwd_changed callback analogous to title_changed via GHOSTTY_TERMINAL_OPT_PWD_CHANGED. The payload is passed through unparsed; embedders read it with ghostty_terminal_get and decode any URI scheme themselves.
This commit is contained in:
@@ -76,6 +76,7 @@ extern "C" {
|
||||
* | `GHOSTTY_TERMINAL_OPT_WRITE_PTY` | `GhosttyTerminalWritePtyFn` | Query responses written back to the pty |
|
||||
* | `GHOSTTY_TERMINAL_OPT_BELL` | `GhosttyTerminalBellFn` | BEL character (0x07) |
|
||||
* | `GHOSTTY_TERMINAL_OPT_TITLE_CHANGED` | `GhosttyTerminalTitleChangedFn` | Title change via OSC 0 / OSC 2 |
|
||||
* | `GHOSTTY_TERMINAL_OPT_PWD_CHANGED` | `GhosttyTerminalPwdChangedFn` | Pwd change via OSC 7 / OSC 9 / OSC 1337 |
|
||||
* | `GHOSTTY_TERMINAL_OPT_ENQUIRY` | `GhosttyTerminalEnquiryFn` | ENQ character (0x05) |
|
||||
* | `GHOSTTY_TERMINAL_OPT_XTVERSION` | `GhosttyTerminalXtversionFn` | XTVERSION query (CSI > q) |
|
||||
* | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS size query (CSI 14/16/18 t) |
|
||||
@@ -372,6 +373,31 @@ typedef bool (*GhosttyTerminalSizeFn)(GhosttyTerminal terminal,
|
||||
typedef void (*GhosttyTerminalTitleChangedFn)(GhosttyTerminal terminal,
|
||||
void* userdata);
|
||||
|
||||
/**
|
||||
* Callback function type for pwd_changed.
|
||||
*
|
||||
* Called when the terminal pwd (current working directory) changes via
|
||||
* escape sequences: OSC 7 (file:// URI), OSC 9 (ConEmu CurrentDir), or
|
||||
* OSC 1337 CurrentDir (iTerm2). Use ghostty_terminal_get() with
|
||||
* GHOSTTY_TERMINAL_DATA_PWD inside the callback to read the new value.
|
||||
*
|
||||
* The terminal stores whatever bytes the shell emitted, without parsing.
|
||||
* That means for OSC 7 the value is the raw URI (typically file://...);
|
||||
* for OSC 9/OSC 1337 it is typically a bare path. The embedder is
|
||||
* responsible for decoding any URI scheme or host if it cares about them.
|
||||
*
|
||||
* The callback also fires when the shell clears the pwd (e.g. an empty
|
||||
* OSC 7). In that case GHOSTTY_TERMINAL_DATA_PWD returns a zero-length
|
||||
* string.
|
||||
*
|
||||
* @param terminal The terminal handle
|
||||
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
typedef void (*GhosttyTerminalPwdChangedFn)(GhosttyTerminal terminal,
|
||||
void* userdata);
|
||||
|
||||
/**
|
||||
* Callback function type for write_pty.
|
||||
*
|
||||
@@ -659,6 +685,14 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
*/
|
||||
GHOSTTY_TERMINAL_OPT_GLYPH_PROTOCOL = 24,
|
||||
|
||||
/**
|
||||
* Callback invoked when the terminal pwd changes via escape
|
||||
* sequences (OSC 7, OSC 9, or OSC 1337 CurrentDir). Set to NULL
|
||||
* to ignore pwd change events.
|
||||
*
|
||||
* Input type: GhosttyTerminalPwdChangedFn
|
||||
*/
|
||||
GHOSTTY_TERMINAL_OPT_PWD_CHANGED = 25,
|
||||
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyTerminalOption;
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ const Effects = struct {
|
||||
enquiry: ?EnquiryFn = null,
|
||||
xtversion: ?XtversionFn = null,
|
||||
title_changed: ?TitleChangedFn = null,
|
||||
pwd_changed: ?PwdChangedFn = null,
|
||||
size_cb: ?SizeFn = null,
|
||||
|
||||
/// Scratch buffer for DA1 feature codes. The device attributes
|
||||
@@ -86,6 +87,9 @@ const Effects = struct {
|
||||
/// C function pointer type for the title_changed callback.
|
||||
pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void;
|
||||
|
||||
/// C function pointer type for the pwd_changed callback.
|
||||
pub const PwdChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void;
|
||||
|
||||
/// C function pointer type for the size callback.
|
||||
/// Returns true and fills out_size if size is available,
|
||||
/// or returns false to silently ignore the query.
|
||||
@@ -199,6 +203,13 @@ const Effects = struct {
|
||||
func(@ptrCast(wrapper), wrapper.effects.userdata);
|
||||
}
|
||||
|
||||
fn pwdChangedTrampoline(handler: *Handler) void {
|
||||
const stream_ptr: *Stream = @fieldParentPtr("handler", handler);
|
||||
const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr);
|
||||
const func = wrapper.effects.pwd_changed orelse return;
|
||||
func(@ptrCast(wrapper), wrapper.effects.userdata);
|
||||
}
|
||||
|
||||
fn sizeTrampoline(handler: *Handler) ?size_report.Size {
|
||||
const stream_ptr: *Stream = @fieldParentPtr("handler", handler);
|
||||
const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr);
|
||||
@@ -283,6 +294,7 @@ fn new_(
|
||||
.enquiry = &Effects.enquiryTrampoline,
|
||||
.xtversion = &Effects.xtversionTrampoline,
|
||||
.title_changed = &Effects.titleChangedTrampoline,
|
||||
.pwd_changed = &Effects.pwdChangedTrampoline,
|
||||
.size = &Effects.sizeTrampoline,
|
||||
};
|
||||
|
||||
@@ -330,6 +342,7 @@ pub const Option = enum(c_int) {
|
||||
default_cursor_style = 22,
|
||||
default_cursor_blink = 23,
|
||||
glyph_protocol = 24,
|
||||
pwd_changed = 25,
|
||||
|
||||
/// Input type expected for setting the option.
|
||||
pub fn InType(comptime self: Option) type {
|
||||
@@ -342,6 +355,7 @@ pub const Option = enum(c_int) {
|
||||
.enquiry => ?Effects.EnquiryFn,
|
||||
.xtversion => ?Effects.XtversionFn,
|
||||
.title_changed => ?Effects.TitleChangedFn,
|
||||
.pwd_changed => ?Effects.PwdChangedFn,
|
||||
.size_cb => ?Effects.SizeFn,
|
||||
.title, .pwd => ?*const lib.String,
|
||||
.color_foreground, .color_background, .color_cursor => ?*const color.RGB.C,
|
||||
@@ -397,6 +411,7 @@ fn setTyped(
|
||||
.enquiry => wrapper.effects.enquiry = value,
|
||||
.xtversion => wrapper.effects.xtversion = value,
|
||||
.title_changed => wrapper.effects.title_changed = value,
|
||||
.pwd_changed => wrapper.effects.pwd_changed = value,
|
||||
.size_cb => wrapper.effects.size_cb = value,
|
||||
.title => {
|
||||
const str = if (value) |v| v.ptr[0..v.len] else "";
|
||||
@@ -2363,6 +2378,68 @@ test "title_changed without callback is silent" {
|
||||
vt_write(t, "\x1B]2;Hello\x1B\\", 10);
|
||||
}
|
||||
|
||||
test "set pwd_changed callback" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
.{
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
.max_scrollback = 0,
|
||||
},
|
||||
));
|
||||
defer free(t);
|
||||
|
||||
const S = struct {
|
||||
var pwd_count: usize = 0;
|
||||
var last_userdata: ?*anyopaque = null;
|
||||
|
||||
fn pwdChanged(_: Terminal, ud: ?*anyopaque) callconv(lib.calling_conv) void {
|
||||
pwd_count += 1;
|
||||
last_userdata = ud;
|
||||
}
|
||||
};
|
||||
S.pwd_count = 0;
|
||||
S.last_userdata = null;
|
||||
|
||||
var sentinel: u8 = 88;
|
||||
try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel)));
|
||||
try testing.expectEqual(Result.success, set(t, .pwd_changed, @ptrCast(&S.pwdChanged)));
|
||||
|
||||
// OSC 7 ; file:///tmp ST — report pwd
|
||||
const seq1 = "\x1B]7;file:///tmp\x1B\\";
|
||||
vt_write(t, seq1, seq1.len);
|
||||
try testing.expectEqual(@as(usize, 1), S.pwd_count);
|
||||
try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
|
||||
try testing.expectEqualStrings("file:///tmp", zigTerminal(t).?.getPwd().?);
|
||||
|
||||
// Another pwd change
|
||||
const seq2 = "\x1B]7;file:///home/user\x1B\\";
|
||||
vt_write(t, seq2, seq2.len);
|
||||
try testing.expectEqual(@as(usize, 2), S.pwd_count);
|
||||
try testing.expectEqualStrings("file:///home/user", zigTerminal(t).?.getPwd().?);
|
||||
}
|
||||
|
||||
test "pwd_changed without callback is silent" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
.{
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
.max_scrollback = 0,
|
||||
},
|
||||
));
|
||||
defer free(t);
|
||||
|
||||
// OSC 7 without a callback should not crash, but should still set the pwd
|
||||
const seq = "\x1B]7;file:///tmp\x1B\\";
|
||||
vt_write(t, seq, seq.len);
|
||||
try testing.expectEqualStrings("file:///tmp", zigTerminal(t).?.getPwd().?);
|
||||
}
|
||||
|
||||
test "set size callback" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
|
||||
@@ -83,6 +83,11 @@ pub const Handler = struct {
|
||||
/// handler.terminal.getTitle().
|
||||
title_changed: ?*const fn (*Handler) void,
|
||||
|
||||
/// Called when the terminal pwd changes via escape sequences
|
||||
/// (e.g. OSC 7). The new pwd can be queried via
|
||||
/// handler.terminal.getPwd().
|
||||
pwd_changed: ?*const fn (*Handler) void,
|
||||
|
||||
/// Called in response to an XTVERSION query. Returns the version
|
||||
/// string to report (e.g. "ghostty 1.2.3"). The returned memory
|
||||
/// must be valid for the lifetime of the call. The maximum length
|
||||
@@ -99,6 +104,7 @@ pub const Handler = struct {
|
||||
.enquiry = null,
|
||||
.size = null,
|
||||
.title_changed = null,
|
||||
.pwd_changed = null,
|
||||
.write_pty = null,
|
||||
.xtversion = null,
|
||||
};
|
||||
@@ -268,6 +274,7 @@ pub const Handler = struct {
|
||||
.request_mode_unknown => self.requestModeUnknown(value.mode, value.ansi),
|
||||
.size_report => self.reportSize(value),
|
||||
.window_title => self.windowTitle(value.title),
|
||||
.report_pwd => self.reportPwd(value.url),
|
||||
.xtversion => self.reportXtversion(),
|
||||
|
||||
// No supported DCS commands have any terminal-modifying effects,
|
||||
@@ -278,7 +285,6 @@ pub const Handler = struct {
|
||||
=> {},
|
||||
|
||||
// Have no terminal-modifying effect
|
||||
.report_pwd,
|
||||
.show_desktop_notification,
|
||||
.progress_report,
|
||||
.clipboard_contents,
|
||||
@@ -437,6 +443,29 @@ pub const Handler = struct {
|
||||
func(self);
|
||||
}
|
||||
|
||||
fn reportPwd(self: *Handler, url_raw: []const u8) void {
|
||||
// Prevent DoS attacks by limiting url length. Headroom for
|
||||
// Linux PATH_MAX (4096) plus URI scheme/host and percent-encoding.
|
||||
const max_url_len = 4096;
|
||||
const url = if (url_raw.len > max_url_len) url: {
|
||||
log.warn("pwd url length {d} exceeds max length {d}, truncating", .{
|
||||
url_raw.len,
|
||||
max_url_len,
|
||||
});
|
||||
break :url url_raw[0..max_url_len];
|
||||
} else url_raw;
|
||||
|
||||
// We store the raw payload unparsed. Embedders read it via
|
||||
// getPwd() and are responsible for decoding any URI scheme.
|
||||
self.terminal.setPwd(url) catch |err| {
|
||||
log.warn("error setting pwd err={}", .{err});
|
||||
return;
|
||||
};
|
||||
|
||||
const func = self.effects.pwd_changed orelse return;
|
||||
func(self);
|
||||
}
|
||||
|
||||
fn requestMode(self: *Handler, mode: modes.Mode) void {
|
||||
const report = self.terminal.modes.getReport(.fromMode(mode));
|
||||
self.sendModeReport(report);
|
||||
|
||||
Reference in New Issue
Block a user