macos: ignore -e arguments as open files (#13748)

Fixes #13319

AppKit treats existing positional arguments as documents, causing paths
passed to a child command after -e to open an extra terminal surface.

We now process args ourselves during openFile callbacks to ignore file
paths after `-e`. There isn't a way to avoid this I can find because
AppKit processes argc/argv from the main entrypoint and that can't be
overridden.
This commit is contained in:
Mitchell Hashimoto
2026-08-11 09:04:38 -07:00
committed by GitHub
4 changed files with 138 additions and 0 deletions

View File

@@ -118,6 +118,7 @@
membershipExceptions = (
App/macOS/AppDelegate.swift,
"App/macOS/AppDelegate+Ghostty.swift",
App/macOS/CommandLineOpenFileFilter.swift,
App/macOS/main.swift,
App/macOS/MainMenu.xib,
Features/About/About.xib,

View File

@@ -92,6 +92,15 @@ class AppDelegate: NSObject,
/// seconds since the process was launched.
private var applicationLaunchTime: TimeInterval = 0
/// AppKit treats positional command-line arguments as documents to open. This
/// filter consumes the corresponding open-file events for arguments following
/// `-e`. It is initialized lazily because most launches never open a file.
private lazy var commandLineOpenFileFilter = CommandLineOpenFileFilter(
arguments: CommandLine.arguments,
workingDirectory: FileManager.default.currentDirectoryPath,
fileExists: { FileManager.default.fileExists(atPath: $0) }
)
/// This is the current configuration from the Ghostty configuration that we need.
private var derivedConfig: DerivedConfig = DerivedConfig()
@@ -435,6 +444,14 @@ class AppDelegate: NSObject,
}
func application(_ sender: NSApplication, openFile filename: String) -> Bool {
// `-e` makes existing path arguments part of the child command, but
// AppKit also reports those paths as documents to open. Only consume
// matching command arguments so unrelated Finder or Dock requests work.
if commandLineOpenFileFilter.shouldIgnore(filename) {
Self.logger.debug("ignoring command argument open-file event path=\(filename, privacy: .public)")
return true
}
// Ghostty will validate as well but we can avoid creating an entirely new
// surface by doing our own validation here. We can also show a useful error
// this way.

View File

@@ -0,0 +1,56 @@
import Foundation
/// Filters the open-file events AppKit creates from command arguments following
/// `-e`. Each matching event is consumed once so later requests to open the same
/// file are handled normally.
final class CommandLineOpenFileFilter {
private let workingDirectory: String
private var filesToIgnore: Set<String>
init(
arguments: [String],
workingDirectory: String,
fileExists: (String) -> Bool
) {
self.workingDirectory = workingDirectory
guard let commandIndex = arguments.firstIndex(of: "-e") else {
self.filesToIgnore = []
return
}
// Ghostty treats every argument following `-e` as part of the child
// command. Existing paths in that suffix are the arguments AppKit can
// independently turn into open-file events.
self.filesToIgnore = Set(arguments[arguments.index(after: commandIndex)...]
.compactMap { argument in
// Command arguments can be relative, while AppKit normally
// reports absolute paths for the corresponding open event.
let path = Self.absolutePath(argument, relativeTo: workingDirectory)
// Ignore only paths that exist during launch. A non-path
// argument cannot produce the duplicate event and retaining it
// could suppress a legitimate open if that path appears later.
return fileExists(path) ? path : nil
})
}
func shouldIgnore(_ filename: String) -> Bool {
let path = Self.absolutePath(filename, relativeTo: workingDirectory)
// Consume each match once. Later requests to open the same file may
// come from Finder, the Dock, or another invocation and must proceed.
return filesToIgnore.remove(path) != nil
}
private static func absolutePath(_ path: String, relativeTo workingDirectory: String) -> String {
let expanded = (path as NSString).expandingTildeInPath
let absolute = if (expanded as NSString).isAbsolutePath {
expanded
} else {
(workingDirectory as NSString).appendingPathComponent(expanded)
}
return (absolute as NSString).standardizingPath
}
}

View File

@@ -0,0 +1,64 @@
import Testing
@testable import Ghostty
@Suite
struct CommandLineOpenFileFilterTests {
@Test func requiresExecuteFlag() {
let filter = CommandLineOpenFileFilter(
arguments: ["ghostty", "/tmp/file.txt"],
workingDirectory: "/tmp",
fileExists: { _ in true }
)
#expect(!filter.shouldIgnore("/tmp/file.txt"))
}
@Test func ignoresExistingPathsAfterExecuteFlag() {
let existing: Set<String> = [
"/usr/bin/vim",
"/tmp/project/file.txt",
"/tmp/other.txt",
]
let filter = CommandLineOpenFileFilter(
arguments: [
"ghostty",
"/tmp/before.txt",
"-e",
"/usr/bin/vim",
"./file.txt",
"../other.txt",
"missing.txt",
],
workingDirectory: "/tmp/project",
fileExists: { existing.contains($0) }
)
#expect(!filter.shouldIgnore("/tmp/before.txt"))
#expect(filter.shouldIgnore("/usr/bin/vim"))
#expect(filter.shouldIgnore("/tmp/project/file.txt"))
#expect(filter.shouldIgnore("/tmp/other.txt"))
#expect(!filter.shouldIgnore("/tmp/project/missing.txt"))
}
@Test func ignoresEachPathOnce() {
let filter = CommandLineOpenFileFilter(
arguments: ["ghostty", "-e", "./file.txt"],
workingDirectory: "/tmp/project",
fileExists: { $0 == "/tmp/project/file.txt" }
)
#expect(filter.shouldIgnore("./file.txt"))
#expect(!filter.shouldIgnore("/tmp/project/file.txt"))
}
@Test func preservesUnrelatedOpenFileRequests() {
let filter = CommandLineOpenFileFilter(
arguments: ["ghostty", "-e", "vim", "/tmp/command-file.txt"],
workingDirectory: "/tmp",
fileExists: { $0 == "/tmp/command-file.txt" }
)
#expect(!filter.shouldIgnore("/tmp/finder-file.txt"))
#expect(filter.shouldIgnore("/tmp/command-file.txt"))
}
}