From 04d5efc8eb7b5f660bf44c0b63b9366c881e9635 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 10 Mar 2026 14:11:35 -0700 Subject: [PATCH] config: working-directory expands ~/ prefix Fixes #11336 Introduce a proper WorkingDirectory tagged union type with home, inherit, and path variants. The field is now an optional (?WorkingDirectory) where null represents "use platform default" which is resolved during Config.finalize to .inherit (CLI) or .home (desktop launcher). --- AGENTS.md | 1 - src/Surface.zig | 2 +- src/apprt/embedded.zig | 10 +- src/apprt/gtk/class/surface.zig | 12 ++- src/apprt/surface.zig | 2 +- src/config.zig | 1 + src/config/Config.zig | 181 ++++++++++++++++++++++++++++---- 7 files changed, 182 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 794115c58..3298f2160 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,6 @@ A file for [guiding coding agents](https://agents.md/). ## Commands -- Use `nix develop -c` with all commands to ensure the Nix version is used. - **Build:** `zig build` - If you're on macOS and don't need to build the macOS app, use `-Demit-macos-app=false` to skip building the app bundle and speed up diff --git a/src/Surface.zig b/src/Surface.zig index a3691b53e..b78812ac4 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -639,7 +639,7 @@ pub fn init( .shell_integration = config.@"shell-integration", .shell_integration_features = config.@"shell-integration-features", .cursor_blink = config.@"cursor-style-blink", - .working_directory = config.@"working-directory", + .working_directory = if (config.@"working-directory") |wd| wd.value() else null, .resources_dir = global_state.resources_dir.host(), .term = config.term, .rt_pre_exec_info = .init(config), diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index c629be498..0d5a4f8da 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -513,7 +513,15 @@ pub const Surface = struct { break :wd; } - config.@"working-directory" = wd; + var wd_val: configpkg.WorkingDirectory = .{ .path = wd }; + if (wd_val.finalize(config.arenaAlloc())) |_| { + config.@"working-directory" = wd_val; + } else |err| { + log.warn( + "error finalizing working directory config dir={s} err={}", + .{ wd_val.path, err }, + ); + } } } diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 8ce9ac1d1..632b0de47 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3381,12 +3381,20 @@ pub const Surface = extern struct { config.command = try c.clone(config._arena.?.allocator()); } if (priv.overrides.working_directory) |wd| { - config.@"working-directory" = try config._arena.?.allocator().dupeZ(u8, wd); + const config_alloc = config.arenaAlloc(); + var wd_val: configpkg.WorkingDirectory = .{ .path = try config_alloc.dupe(u8, wd) }; + try wd_val.finalize(config_alloc); + config.@"working-directory" = wd_val; } // Properties that can impact surface init if (priv.font_size_request) |size| config.@"font-size" = size.points; - if (priv.pwd) |pwd| config.@"working-directory" = pwd; + if (priv.pwd) |pwd| { + const config_alloc = config.arenaAlloc(); + var wd_val: configpkg.WorkingDirectory = .{ .path = try config_alloc.dupe(u8, pwd) }; + try wd_val.finalize(config_alloc); + config.@"working-directory" = wd_val; + } // Initialize the surface surface.init( diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 5c25281c8..3cb0016fa 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -188,7 +188,7 @@ pub fn newConfig( if (prev) |p| { if (shouldInheritWorkingDirectory(context, config)) { if (try p.pwd(alloc)) |pwd| { - copy.@"working-directory" = pwd; + copy.@"working-directory" = .{ .path = pwd }; } } } diff --git a/src/config.zig b/src/config.zig index 0bf61a47f..314fb49ee 100644 --- a/src/config.zig +++ b/src/config.zig @@ -44,6 +44,7 @@ pub const WindowPaddingColor = Config.WindowPaddingColor; pub const BackgroundImagePosition = Config.BackgroundImagePosition; pub const BackgroundImageFit = Config.BackgroundImageFit; pub const LinkPreviews = Config.LinkPreviews; +pub const WorkingDirectory = Config.WorkingDirectory; // Alternate APIs pub const CApi = @import("config/CApi.zig"); diff --git a/src/config/Config.zig b/src/config/Config.zig index e31fbc011..aae6c9e20 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1526,13 +1526,14 @@ class: ?[:0]const u8 = null, /// `open`, then it defaults to `home`. On Linux with GTK, if Ghostty can detect /// it was launched from a desktop launcher, then it defaults to `home`. /// -/// The value of this must be an absolute value or one of the special values -/// below: +/// The value of this must be an absolute path, a path prefixed with `~/` +/// (the tilde will be expanded to the user's home directory), or +/// one of the special values below: /// /// * `home` - The home directory of the executing user. /// /// * `inherit` - The working directory of the launching process. -@"working-directory": ?[]const u8 = null, +@"working-directory": ?WorkingDirectory = null, /// Key bindings. The format is `trigger=action`. Duplicate triggers will /// overwrite previously set values. The list of actions is available in @@ -4519,23 +4520,18 @@ pub fn finalize(self: *Config) !void { } // The default for the working directory depends on the system. - const wd = self.@"working-directory" orelse if (probable_cli) - // From the CLI, we want to inherit where we were launched from. - "inherit" + var wd: WorkingDirectory = self.@"working-directory" orelse if (probable_cli) + .inherit else - // Otherwise we typically just want the home directory because - // our pwd is probably a runtime state dir or root or something - // (launchers and desktop environments typically do this). - "home"; + .home; // If we are missing either a command or home directory, we need // to look up defaults which is kind of expensive. We only do this // on desktop. - const wd_home = std.mem.eql(u8, "home", wd); if ((comptime !builtin.target.cpu.arch.isWasm()) and (comptime !builtin.is_test)) { - if (self.command == null or wd_home) command: { + if (self.command == null or wd == .home) command: { // First look up the command using the SHELL env var if needed. // We don't do this in flatpak because SHELL in Flatpak is always // set to /bin/sh. @@ -4557,7 +4553,7 @@ pub fn finalize(self: *Config) !void { self.command = .{ .shell = copy }; // If we don't need the working directory, then we can exit now. - if (!wd_home) break :command; + if (wd != .home) break :command; } else |_| {} } @@ -4568,10 +4564,12 @@ pub fn finalize(self: *Config) !void { self.command = .{ .shell = "cmd.exe" }; } - if (wd_home) { + if (wd == .home) { var buf: [std.fs.max_path_bytes]u8 = undefined; if (try internal_os.home(&buf)) |home| { - self.@"working-directory" = try alloc.dupe(u8, home); + wd = .{ .path = try alloc.dupe(u8, home) }; + } else { + wd = .inherit; } } }, @@ -4586,10 +4584,12 @@ pub fn finalize(self: *Config) !void { } } - if (wd_home) { + if (wd == .home) { if (pw.home) |home| { log.info("default working directory src=passwd value={s}", .{home}); - self.@"working-directory" = home; + wd = .{ .path = home }; + } else { + wd = .inherit; } } @@ -4600,6 +4600,8 @@ pub fn finalize(self: *Config) !void { } } } + try wd.finalize(alloc); + self.@"working-directory" = wd; // Apprt-specific defaults switch (build_config.app_runtime) { @@ -4618,10 +4620,6 @@ pub fn finalize(self: *Config) !void { }, } - // If we have the special value "inherit" then set it to null which - // does the same. In the future we should change to a tagged union. - if (std.mem.eql(u8, wd, "inherit")) self.@"working-directory" = null; - // Default our click interval if (self.@"click-repeat-interval" == 0 and (comptime !builtin.is_test)) @@ -5245,6 +5243,127 @@ pub const LinkPreviews = enum { osc8, }; +/// See working-directory +pub const WorkingDirectory = union(enum) { + const Self = @This(); + + /// Resolve to the current user's home directory during config finalize. + home, + + /// Inherit the working directory from the launching process. + inherit, + + /// Use an explicit working directory path. This may be not be + /// expanded until finalize is called. + path: []const u8, + + pub fn parseCLI(self: *Self, alloc: Allocator, input_: ?[]const u8) !void { + var input = input_ orelse return error.ValueRequired; + input = std.mem.trim(u8, input, &std.ascii.whitespace); + if (input.len == 0) return error.ValueRequired; + + // Match path.zig behavior for quoted values. + if (input.len >= 2 and input[0] == '"' and input[input.len - 1] == '"') { + input = input[1 .. input.len - 1]; + } + + if (std.mem.eql(u8, input, "home")) { + self.* = .home; + return; + } + + if (std.mem.eql(u8, input, "inherit")) { + self.* = .inherit; + return; + } + + self.* = .{ .path = try alloc.dupe(u8, input) }; + } + + /// Expand tilde paths in .path values. + pub fn finalize(self: *Self, alloc: Allocator) Allocator.Error!void { + const path = switch (self.*) { + .path => |path| path, + else => return, + }; + + if (!std.mem.startsWith(u8, path, "~/")) return; + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const expanded = internal_os.expandHome(path, &buf) catch |err| { + log.warn( + "error expanding home directory for working-directory path={s}: {}", + .{ path, err }, + ); + return; + }; + + if (std.mem.eql(u8, expanded, path)) return; + self.* = .{ .path = try alloc.dupe(u8, expanded) }; + } + + pub fn value(self: Self) ?[]const u8 { + return switch (self) { + .path => |path| path, + .home, .inherit => null, + }; + } + + pub fn clone(self: Self, alloc: Allocator) Allocator.Error!Self { + return switch (self) { + .path => |path| .{ .path = try alloc.dupe(u8, path) }, + else => self, + }; + } + + pub fn formatEntry(self: Self, formatter: formatterpkg.EntryFormatter) !void { + switch (self) { + .home, .inherit => try formatter.formatEntry([]const u8, @tagName(self)), + .path => |path| try formatter.formatEntry([]const u8, path), + } + } + + test "WorkingDirectory parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var wd: Self = .inherit; + + try wd.parseCLI(alloc, "inherit"); + try testing.expectEqual(.inherit, wd); + + try wd.parseCLI(alloc, "home"); + try testing.expectEqual(.home, wd); + + try wd.parseCLI(alloc, "~/projects/ghostty"); + try testing.expectEqualStrings("~/projects/ghostty", wd.path); + + try wd.parseCLI(alloc, "\"/tmp path\""); + try testing.expectEqualStrings("/tmp path", wd.path); + } + + test "WorkingDirectory finalize" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + { + var wd: Self = .{ .path = "~/projects/ghostty" }; + try wd.finalize(alloc); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const expected = internal_os.expandHome( + "~/projects/ghostty", + &buf, + ) catch "~/projects/ghostty"; + try testing.expectEqualStrings(expected, wd.value().?); + } + } +}; + /// Color represents a color using RGB. /// /// This is a packed struct so that the C API to read color values just @@ -10309,6 +10428,26 @@ test "clone preserves conditional set" { try testing.expect(clone1._conditional_set.contains(.theme)); } +test "working-directory expands tilde" { + const testing = std.testing; + const alloc = testing.allocator; + + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--working-directory=~/projects/ghostty", + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const expected = internal_os.expandHome( + "~/projects/ghostty", + &buf, + ) catch "~/projects/ghostty"; + try testing.expectEqualStrings(expected, cfg.@"working-directory".?.value().?); +} + test "changed" { const testing = std.testing; const alloc = testing.allocator;