cli: +edit-config command to open the config file in $EDITOR

This adds a new CLI `ghostty +edit-config`. This will open the config
file in the user's specified `$EDITOR`. If Ghostty has never been
configured, this will also create the initial config file with some
default templated contents (the same as that which we introduced back in
Ghostty 1.0.1 or something -- not new behavior here).

This is useful on its own because it will find the correct configuration
path to open. If users are terminal users anyway (not a big stretch
since this is a terminal app), this will allow them to easily edit
config right away.

This is also forward looking: I want to replace our "Open Config" action
to open a Ghostty window executing this command so that users can edit
their config in a terminal editor. This has been heavily requested since
forever (short of a full GUI settings editor, which is not ready yet). I
don't do this in this PR but plan to in a future PR.
This commit is contained in:
Mitchell Hashimoto
2025-06-23 12:29:24 -04:00
parent 3e79c4b7ea
commit 865ba546a9
4 changed files with 274 additions and 16 deletions

View File

@@ -9,6 +9,7 @@ const list_keybinds = @import("list_keybinds.zig");
const list_themes = @import("list_themes.zig");
const list_colors = @import("list_colors.zig");
const list_actions = @import("list_actions.zig");
const edit_config = @import("edit_config.zig");
const show_config = @import("show_config.zig");
const validate_config = @import("validate_config.zig");
const crash_report = @import("crash_report.zig");
@@ -40,6 +41,9 @@ pub const Action = enum {
/// List keybind actions
@"list-actions",
/// Edit the config file in the configured terminal editor.
@"edit-config",
/// Dump the config to stdout
@"show-config",
@@ -151,6 +155,7 @@ pub const Action = enum {
.@"list-themes" => try list_themes.run(alloc),
.@"list-colors" => try list_colors.run(alloc),
.@"list-actions" => try list_actions.run(alloc),
.@"edit-config" => try edit_config.run(alloc),
.@"show-config" => try show_config.run(alloc),
.@"validate-config" => try validate_config.run(alloc),
.@"crash-report" => try crash_report.run(alloc),
@@ -187,6 +192,7 @@ pub const Action = enum {
.@"list-themes" => list_themes.Options,
.@"list-colors" => list_colors.Options,
.@"list-actions" => list_actions.Options,
.@"edit-config" => edit_config.Options,
.@"show-config" => show_config.Options,
.@"validate-config" => validate_config.Options,
.@"crash-report" => crash_report.Options,

159
src/cli/edit_config.zig Normal file
View File

@@ -0,0 +1,159 @@
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const args = @import("args.zig");
const Allocator = std.mem.Allocator;
const Action = @import("action.zig").Action;
const configpkg = @import("../config.zig");
const internal_os = @import("../os/main.zig");
const Config = configpkg.Config;
pub const Options = struct {
pub fn deinit(self: Options) void {
_ = self;
}
/// Enables `-h` and `--help` to work.
pub fn help(self: Options) !void {
_ = self;
return Action.help_error;
}
};
/// The `edit-config` command opens the Ghostty configuration file in the
/// editor specified by the `$VISUAL` or `$EDITOR` environment variables.
///
/// IMPORTANT: This command will not reload the configuration after
/// editing. You will need to manually reload the configuration using the
/// application menu, configured keybind, or by restarting Ghostty. We
/// plan to auto-reload in the future, but Ghostty isn't capable of
/// this yet.
///
/// The filepath opened is the default user-specific configuration
/// file, which is typically located at `$XDG_CONFIG_HOME/ghostty/config`.
/// On macOS, this may also be located at
/// `~/Library/Application Support/com.mitchellh.ghostty/config`.
/// On macOS, whichever path exists and is non-empty will be prioritized,
/// prioritizing the Application Support directory if neither are
/// non-empty.
///
/// This command prefers the `$VISUAL` environment variable over `$EDITOR`,
/// if both are set. If neither are set, it will print an error
/// and exit.
pub fn run(alloc: Allocator) !u8 {
// Implementation note (by @mitchellh): I do proper memory cleanup
// throughout this command, even though we plan on doing `exec`.
// I do this out of good hygiene in case we ever change this to
// not using `exec` anymore and because this command isn't performance
// critical where setting up the defer cleanup is a problem.
const stderr = std.io.getStdErr().writer();
var opts: Options = .{};
defer opts.deinit();
{
var iter = try args.argsIterator(alloc);
defer iter.deinit();
try args.parse(Options, alloc, &opts, &iter);
}
// We load the configuration once because that will write our
// default configuration files to disk. We don't use the config.
var config = try Config.load(alloc);
defer config.deinit();
// Find the preferred path.
const path = try Config.preferredDefaultFilePath(alloc);
defer alloc.free(path);
// We don't currently support Windows because we use the exec syscall.
if (comptime builtin.os.tag == .windows) {
try stderr.print(
\\The `ghostty +edit-config` command is not supported on Windows.
\\Please edit the configuration file manually at the following path:
\\
\\{s}
\\
,
.{path},
);
return 1;
}
// Get our editor
const get_env_: ?internal_os.GetEnvResult = env: {
// VISUAL vs. EDITOR: https://unix.stackexchange.com/questions/4859/visual-vs-editor-what-s-the-difference
if (try internal_os.getenv(alloc, "VISUAL")) |v| {
if (v.value.len > 0) break :env v;
v.deinit(alloc);
}
if (try internal_os.getenv(alloc, "EDITOR")) |v| {
if (v.value.len > 0) break :env v;
v.deinit(alloc);
}
break :env null;
};
defer if (get_env_) |v| v.deinit(alloc);
const editor: []const u8 = if (get_env_) |v| v.value else "";
// If we don't have `$EDITOR` set then we can't do anything
// but we can still print a helpful message.
if (editor.len == 0) {
try stderr.print(
\\The $EDITOR or $VISUAL environment variable is not set or is empty.
\\This environment variable is required to edit the Ghostty configuration
\\via this CLI command.
\\
\\Please set the environment variable to your preferred terminal
\\text editor and try again.
\\
\\If you prefer to edit the configuration file another way,
\\you can find the configuration file at the following path:
\\
\\
,
.{},
);
// Output the path using the OSC8 sequence so that it is linked.
try stderr.print(
"\x1b]8;;file://{s}\x1b\\{s}\x1b]8;;\x1b\\\n",
.{ path, path },
);
return 1;
}
// We require libc because we want to use std.c.environ for envp
// and not have to build that ourselves. We can remove this
// limitation later but Ghostty already heavily requires libc
// so this is not a big deal.
comptime assert(builtin.link_libc);
const editorZ = try alloc.dupeZ(u8, editor);
defer alloc.free(editorZ);
const pathZ = try alloc.dupeZ(u8, path);
defer alloc.free(pathZ);
const err = std.posix.execvpeZ(
editorZ,
&.{ editorZ, pathZ },
std.c.environ,
);
// If we reached this point then exec failed.
try stderr.print(
\\Failed to execute the editor. Error code={}.
\\
\\This is usually due to the executable path not existing, invalid
\\permissions, or the shell environment not being set up
\\correctly.
\\
\\Editor: {s}
\\Path: {s}
\\
, .{ err, editor, path });
return 1;
}

View File

@@ -2759,24 +2759,20 @@ pub fn loadIter(
/// `path` must be resolved and absolute.
pub fn loadFile(self: *Config, alloc: Allocator, path: []const u8) !void {
assert(std.fs.path.isAbsolute(path));
var file = try std.fs.openFileAbsolute(path, .{});
defer file.close();
const stat = try file.stat();
switch (stat.kind) {
.file => {},
else => |kind| {
log.warn("config-file {s}: not reading because file type is {s}", .{
path,
@tagName(kind),
});
var file = openFile(path) catch |err| switch (err) {
error.NotAFile => {
log.warn(
"config-file {s}: not reading because it is not a file",
.{path},
);
return;
},
}
else => return err,
};
defer file.close();
std.log.info("reading configuration file path={s}", .{path});
var buf_reader = std.io.bufferedReader(file.reader());
const reader = buf_reader.reader();
const Iter = cli.args.LineIterator(@TypeOf(reader));
@@ -2831,13 +2827,13 @@ fn writeConfigTemplate(path: []const u8) !void {
/// is also loaded.
pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void {
// Load XDG first
const xdg_path = try internal_os.xdg.config(alloc, .{ .subdir = "ghostty/config" });
const xdg_path = try defaultXdgPath(alloc);
defer alloc.free(xdg_path);
const xdg_action = self.loadOptionalFile(alloc, xdg_path);
// On macOS load the app support directory as well
if (comptime builtin.os.tag == .macos) {
const app_support_path = try internal_os.macos.appSupportDir(alloc, "config");
const app_support_path = try defaultAppSupportPath(alloc);
defer alloc.free(app_support_path);
const app_support_action = self.loadOptionalFile(alloc, app_support_path);
@@ -2857,6 +2853,102 @@ pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void {
}
}
/// Default path for the XDG home configuration file. Returned value
/// must be freed by the caller.
fn defaultXdgPath(alloc: Allocator) ![]const u8 {
return try internal_os.xdg.config(
alloc,
.{ .subdir = "ghostty/config" },
);
}
/// Default path for the macOS Application Support configuration file.
/// Returned value must be freed by the caller.
fn defaultAppSupportPath(alloc: Allocator) ![]const u8 {
return try internal_os.macos.appSupportDir(alloc, "config");
}
/// Returns the path to the preferred default configuration file.
/// This is the file where users should place their configuration.
///
/// This doesn't create or populate the file with any default
/// contents; downstream callers must handle this.
///
/// The returned value must be freed by the caller.
pub fn preferredDefaultFilePath(alloc: Allocator) ![]const u8 {
switch (builtin.os.tag) {
.macos => {
// macOS prefers the Application Support directory
// if it exists.
const app_support_path = try defaultAppSupportPath(alloc);
if (openFile(app_support_path)) |f| {
f.close();
return app_support_path;
} else |_| {}
// Try the XDG path if it exists
const xdg_path = try defaultXdgPath(alloc);
if (openFile(xdg_path)) |f| {
f.close();
alloc.free(app_support_path);
return xdg_path;
} else |_| {}
defer alloc.free(xdg_path);
// Neither exist, use app support
return app_support_path;
},
// All other platforms use XDG only
else => return try defaultXdgPath(alloc),
}
}
const OpenFileError = error{
FileNotFound,
FileIsEmpty,
FileOpenFailed,
NotAFile,
};
/// Opens the file at the given path and returns the file handle
/// if it exists and is non-empty. This also constrains the possible
/// errors to a smaller set that we can explicitly handle.
fn openFile(path: []const u8) OpenFileError!std.fs.File {
assert(std.fs.path.isAbsolute(path));
var file = std.fs.openFileAbsolute(
path,
.{},
) catch |err| switch (err) {
error.FileNotFound => return OpenFileError.FileNotFound,
else => {
log.warn("unexpected file open error path={s} err={}", .{
path,
err,
});
return OpenFileError.FileOpenFailed;
},
};
errdefer file.close();
const stat = file.stat() catch |err| {
log.warn("error getting file stat path={s} err={}", .{
path,
err,
});
return OpenFileError.FileOpenFailed;
};
switch (stat.kind) {
.file => {},
else => return OpenFileError.NotAFile,
}
if (stat.size == 0) return OpenFileError.FileIsEmpty;
return file;
}
/// Load and parse the CLI args.
pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void {
switch (builtin.os.tag) {

View File

@@ -29,6 +29,7 @@ pub const shell = @import("shell.zig");
// Functions and types
pub const CFReleaseThread = @import("cf_release_thread.zig");
pub const TempDir = @import("TempDir.zig");
pub const GetEnvResult = env.GetEnvResult;
pub const getEnvMap = env.getEnvMap;
pub const appendEnv = env.appendEnv;
pub const appendEnvAlways = env.appendEnvAlways;