diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 6d883ded8..c7f5157e0 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -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, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 544d86d45..127f3a014 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -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. diff --git a/macos/Sources/App/macOS/CommandLineOpenFileFilter.swift b/macos/Sources/App/macOS/CommandLineOpenFileFilter.swift new file mode 100644 index 000000000..b4a92b64e --- /dev/null +++ b/macos/Sources/App/macOS/CommandLineOpenFileFilter.swift @@ -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 + + 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 + } +} diff --git a/macos/Tests/CommandLineOpenFileFilterTests.swift b/macos/Tests/CommandLineOpenFileFilterTests.swift new file mode 100644 index 000000000..9a302a1c0 --- /dev/null +++ b/macos/Tests/CommandLineOpenFileFilterTests.swift @@ -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 = [ + "/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")) + } +}