From b5342e8b7fcb36058b66774ed36de5918e5c7c1e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 14 Jul 2026 15:45:32 -0700 Subject: [PATCH 1/6] test: cover relative terminal path references --- .../TerminalPathResolverTests.swift | 44 +++++++++++++++++++ .../GhosttyRuntimeTestStubs.c | 11 +++++ .../include/GhosttyRuntimeTestStubs.h | 9 ++++ 3 files changed, 64 insertions(+) diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift index 526fe73d29f9..a33e867b4cf4 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift @@ -145,6 +145,50 @@ private func existsIn(_ existingPaths: Set) -> @Sendable (String) -> Boo } @Suite struct TerminalOpenURLFilePathTests { + @Test(arguments: [ + ("./scripts/reload.sh", "/Users/dev/project/scripts/reload.sh"), + ("../Shared/Package.swift", "/Users/dev/Shared/Package.swift"), + ("Sources/App/SettingsWindowFactory.swift", "/Users/dev/project/Sources/App/SettingsWindowFactory.swift"), + ("a/b/c", "/Users/dev/project/a/b/c"), + ]) + func resolvesRelativePathForms(rawPath: String, existingFile: String) { + #expect( + TerminalPathResolver(fileExists: existsIn([existingFile])).resolveOpenURLFilePath( + rawPath, + cwd: "/Users/dev/project" + ) == existingFile + ) + } + + @Test func resolvesRelativePathWithLineSuffix() { + let existingFile = "/Users/dev/project/Sources/App/SettingsWindowFactory.swift" + #expect( + TerminalPathResolver(fileExists: existsIn([existingFile])).resolveOpenURLFilePath( + "Sources/App/SettingsWindowFactory.swift:12", + cwd: "/Users/dev/project" + ) == existingFile + ) + } + + @Test func resolvesRelativePathWithLineAndColumnSuffix() { + let existingFile = "/Users/dev/project/Sources/App/SettingsWindowFactory.swift" + #expect( + TerminalPathResolver(fileExists: existsIn([existingFile])).resolveOpenURLFilePath( + "Sources/App/SettingsWindowFactory.swift:12:7", + cwd: "/Users/dev/project" + ) == existingFile + ) + } + + @Test func locationSuffixDoesNotBypassExistenceCheck() { + #expect( + TerminalPathResolver(fileExists: existsIn([])).resolveOpenURLFilePath( + "Sources/App/Missing.swift:12:7", + cwd: "/Users/dev/project" + ) == nil + ) + } + @Test func resolvesAbsoluteMarkdownPathWithTrailingDot() { let existingFile = "/Users/dev/project/skills/marketing/data/lawrencecchen-tweets.md" #expect( diff --git a/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c b/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c index 3e67ed7a11c5..e73d7b42c757 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c +++ b/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c @@ -9,3 +9,14 @@ void *ghostty_surface_quicklook_font(void *surface) { (void)surface; return 0; } + +void *ghostty_surface_new_with_scrollback_limit( + void *app, + const void *config, + uintptr_t scrollback_limit_bytes +) { + (void)app; + (void)config; + (void)scrollback_limit_bytes; + return 0; +} diff --git a/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h b/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h index b9c210917d34..b348d46d2764 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h +++ b/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h @@ -2,6 +2,7 @@ #define GHOSTTY_RUNTIME_TEST_STUBS_H #include +#include // Test-only stand-in for the libghostty symbol bound by @_silgen_name in // GhosttyRuntimeCInterop. SwiftPM cannot link the GhosttyKit macOS archive @@ -13,4 +14,12 @@ bool ghostty_surface_clear_selection(void *surface); // GhosttySurfaceRuntimeProbe.currentSurfaceFontSizePoints; no test calls it. void *ghostty_surface_quicklook_font(void *surface); +// Test-only stand-in for the GhosttyKit symbol referenced by +// GhosttyRuntimeCInterop.createSurface; no package test calls it. +void *ghostty_surface_new_with_scrollback_limit( + void *app, + const void *config, + uintptr_t scrollback_limit_bytes +); + #endif From 3761a002e9921e1be9a32b81b57a3a3d0e9058ac Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 14 Jul 2026 16:30:21 -0700 Subject: [PATCH 2/6] feat: resolve terminal paths from surface context --- .../String+TerminalPathTokens.swift | 34 +++ .../TerminalPathResolution.swift | 24 ++ .../TerminalPathResolutionContext.swift | 21 ++ .../PathResolution/TerminalPathResolver.swift | 226 +++++++++++++++--- .../TerminalPathResolverTests.swift | 158 +++++++++++- .../FileOpen/PreferredEditorService.swift | 30 ++- .../PreferredEditorServiceTests.swift | 45 ++++ Sources/CommandClickFileOpenRouter.swift | 81 ++++++- Sources/GhosttyApp+EmbeddedBrowserLink.swift | 72 ++++++ Sources/GhosttyTerminalView.swift | 196 ++++++--------- Sources/TerminalBrowserHostNormalizer.swift | 19 ++ cmux.xcodeproj/project.pbxproj | 8 + cmuxTests/TerminalAndGhosttyTests.swift | 12 +- 13 files changed, 746 insertions(+), 180 deletions(-) create mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolution.swift create mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolutionContext.swift create mode 100644 Sources/GhosttyApp+EmbeddedBrowserLink.swift create mode 100644 Sources/TerminalBrowserHostNormalizer.swift diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/String+TerminalPathTokens.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/String+TerminalPathTokens.swift index d331985565e8..9456c7c83937 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/String+TerminalPathTokens.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/String+TerminalPathTokens.swift @@ -156,6 +156,40 @@ extension String { return candidates } + /// Candidate path references derived from a single normalized spelling. + /// + /// The literal spelling is first so a real file whose name ends in + /// `:number` wins. A trailing `:line` or `:line:column` interpretation is + /// second and is only observable when its stripped path exists. + func terminalPathReferenceCandidates() -> [(path: String, line: Int?, column: Int?)] { + var candidates = [(path: self, line: Optional.none, column: Optional.none)] + let components = split(separator: ":", omittingEmptySubsequences: false) + guard components.count >= 2, + let trailingNumber = components.last.flatMap({ Int($0) }), + trailingNumber > 0 else { + return candidates + } + + let path: String + let line: Int + let column: Int? + if components.count >= 3, + let lineNumber = Int(components[components.count - 2]), + lineNumber > 0 { + path = components.dropLast(2).joined(separator: ":") + line = lineNumber + column = trailingNumber + } else { + path = components.dropLast().joined(separator: ":") + line = trailingNumber + column = nil + } + + guard !path.isEmpty else { return candidates } + candidates.append((path: path, line: line, column: column)) + return candidates + } + /// Path-token candidates around a column of a visible terminal line: the /// raw whitespace-delimited segment first, then the shell-escape-aware /// token. diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolution.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolution.swift new file mode 100644 index 000000000000..faa4da8c70cb --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolution.swift @@ -0,0 +1,24 @@ +/// An existing terminal path together with an optional source location. +public struct TerminalPathResolution: Equatable, Sendable { + /// The standardized absolute path that passed the existence probe. + public let path: String + + /// The one-based line parsed from a `:line` suffix, when present. + public let line: Int? + + /// The one-based column parsed from a `:line:column` suffix, when present. + public let column: Int? + + /// Creates a resolved path reference. + /// + /// - Parameters: + /// - path: A standardized absolute path. Resolver-produced values are + /// guaranteed to exist at resolution time. + /// - line: An optional one-based line. + /// - column: An optional one-based column. + public init(path: String, line: Int?, column: Int?) { + self.path = path + self.line = line + self.column = column + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolutionContext.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolutionContext.swift new file mode 100644 index 000000000000..87214b063912 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolutionContext.swift @@ -0,0 +1,21 @@ +/// Ordered directories used to resolve a relative path emitted by a terminal. +public struct TerminalPathResolutionContext: Equatable, Sendable { + /// The clicked surface's current working directory. + public let workingDirectory: String? + + /// Lower-priority roots, such as the enclosing repository and workspace. + public let fallbackDirectories: [String] + + /// Creates a path-resolution context. + /// + /// - Parameters: + /// - workingDirectory: The clicked surface's current working directory. + /// - fallbackDirectories: Roots to try after the working directory. + public init( + workingDirectory: String?, + fallbackDirectories: [String] = [] + ) { + self.workingDirectory = workingDirectory + self.fallbackDirectories = fallbackDirectories + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift index 3b6476d5ac45..653ae893e18c 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift @@ -7,7 +7,8 @@ public import Foundation /// from the pure `String` transforms in this domain (shell-token unquoting /// and unescaping, trailing-punctuation trimming, visible-line /// tokenization); the resolver expands them for `~`, resolves relative -/// candidates against the surface cwd, standardizes, and probes in order. +/// candidates against an ordered surface context, standardizes, and probes in +/// order. Optional `:line[:column]` suffixes survive as structured metadata. /// /// The resolver is an instantiated value because resolution is pure only up /// to the file system: every resolve probes candidates for existence, so the @@ -15,6 +16,10 @@ public import Foundation /// file system; tests inject a fake probe. This mirrors /// ``TerminalLinkRouter``'s injected `BrowserHostNormalizing` seam. public struct TerminalPathResolver: Sendable { + private static let explicitURLSchemes: Set = [ + "file", "ftp", "gemini", "git", "gopher", "http", "https", "ipfs", + "ipns", "magnet", "mailto", "news", "ssh", "tel", + ] private let fileExists: @Sendable (String) -> Bool /// Creates a resolver that probes candidate paths through `fileExists`. @@ -27,45 +32,83 @@ public struct TerminalPathResolver: Sendable { self.fileExists = fileExists } - /// Resolves raw terminal text to an existing file path for QuickLook. + /// Resolves terminal text to an existing absolute path and source location. /// - /// Candidates are derived from the raw text (as-is, shell-unescaped, - /// shell-unquoted, and trailing-punctuation-trimmed variants), expanded - /// for `~`, resolved against `cwd` when relative, standardized, and probed - /// in order. The first existing path wins. + /// Literal spellings are probed before treating a numeric suffix as source + /// location metadata. Relative spellings are resolved against the surface + /// working directory, then each fallback root. Only an existing path is + /// returned. /// /// - Parameters: - /// - rawText: The raw text under the cursor or selection. - /// - cwd: The surface's working directory used for relative candidates. - /// - Returns: The first existing standardized path, or `nil`. - public func resolveQuicklookPath(_ rawText: String, cwd: String?) -> String? { + /// - rawText: The raw terminal token or selected text. + /// - context: The surface working directory and ordered fallback roots. + /// - Returns: The first existing path reference, or `nil`. + public func resolvePath( + _ rawText: String, + context: TerminalPathResolutionContext + ) -> TerminalPathResolution? { let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } - + let spellings = trimmed.pathResolutionCandidates() + guard !spellings.contains(where: hasExplicitURLScheme) else { + return nil + } + let references = spellings.filter { !shouldBypassPathResolutionForURL($0) }.flatMap { + $0.terminalPathReferenceCandidates() + } + let directories = resolutionDirectories(context: context) var seenPaths: Set = [] - for token in trimmed.pathResolutionCandidates() { - let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedToken.isEmpty else { continue } - - let expandedToken = (normalizedToken as NSString).expandingTildeInPath - let candidatePath: String - if expandedToken.hasPrefix("/") { - candidatePath = expandedToken - } else { - guard let cwd, !cwd.isEmpty else { continue } - candidatePath = (cwd as NSString).appendingPathComponent(expandedToken) + + for directory in directories { + for reference in references { + let expandedPath = (reference.path as NSString).expandingTildeInPath + guard !expandedPath.hasPrefix("/") else { continue } + let candidatePath = (directory as NSString).appendingPathComponent(expandedPath) + if let resolution = existingResolution( + path: candidatePath, + line: reference.line, + column: reference.column, + seenPaths: &seenPaths + ) { + return resolution + } } + } - let standardizedPath = (candidatePath as NSString).standardizingPath - guard seenPaths.insert(standardizedPath).inserted else { continue } - if fileExists(standardizedPath) { - return standardizedPath + for reference in references { + let expandedPath = (reference.path as NSString).expandingTildeInPath + guard expandedPath.hasPrefix("/") else { continue } + if let resolution = existingResolution( + path: expandedPath, + line: reference.line, + column: reference.column, + seenPaths: &seenPaths + ) { + return resolution } } return nil } + /// Resolves raw terminal text to an existing file path for QuickLook. + /// + /// Candidates are derived from the raw text (as-is, shell-unescaped, + /// shell-unquoted, and trailing-punctuation-trimmed variants), expanded + /// for `~`, resolved against `cwd` when relative, standardized, and probed + /// in order. The first existing path wins. + /// + /// - Parameters: + /// - rawText: The raw text under the cursor or selection. + /// - cwd: The surface's working directory used for relative candidates. + /// - Returns: The first existing standardized path, or `nil`. + public func resolveQuicklookPath(_ rawText: String, cwd: String?) -> String? { + resolvePath( + rawText, + context: TerminalPathResolutionContext(workingDirectory: cwd) + )?.path + } + /// Resolves the path token under a column of a visible terminal line. /// /// Tries the raw whitespace-delimited segment around the column first, @@ -90,6 +133,26 @@ public struct TerminalPathResolver: Sendable { return nil } + /// Resolves the path reference under a visible terminal column. + /// + /// - Parameters: + /// - line: The visible line text. + /// - column: The zero-based column under the cursor. + /// - context: The surface working directory and ordered fallback roots. + /// - Returns: The raw token plus its structured resolution, or `nil`. + public func resolveVisibleLineReference( + _ line: String, + column: Int, + context: TerminalPathResolutionContext + ) -> (rawToken: String, resolution: TerminalPathResolution)? { + for rawToken in line.pathTokenCandidates(containingColumn: column) { + if let resolution = resolvePath(rawToken, context: context) { + return (rawToken, resolution) + } + } + return nil + } + /// Resolves an open-URL request payload to an existing file path. /// /// Text that parses as a URL with a scheme is never treated as a file @@ -100,9 +163,116 @@ public struct TerminalPathResolver: Sendable { /// - cwd: The surface's working directory. /// - Returns: The first existing standardized path, or `nil`. public func resolveOpenURLFilePath(_ rawText: String, cwd: String?) -> String? { + resolveOpenURLFileReference( + rawText, + context: TerminalPathResolutionContext(workingDirectory: cwd) + )?.path + } + + /// Resolves an open-URL payload as an existing local path reference. + /// + /// Explicit URL schemes remain on the URL-routing path. Schemeless text is + /// resolved through ``resolvePath(_:context:)``. + /// + /// - Parameters: + /// - rawText: The raw open-URL text from the runtime. + /// - context: The surface working directory and ordered fallback roots. + /// - Returns: The first existing path reference, or `nil`. + public func resolveOpenURLFileReference( + _ rawText: String, + context: TerminalPathResolutionContext + ) -> TerminalPathResolution? { let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } - guard URL(string: trimmed)?.scheme == nil else { return nil } - return resolveQuicklookPath(trimmed, cwd: cwd) + return resolvePath(trimmed, context: context) + } + + /// Whether text is unambiguously shaped like a relative path reference. + /// + /// This classifier is used only after existence-gated resolution fails, so + /// callers can consume path-like text instead of passing it to a browser + /// heuristic. Bare domains remain URL candidates; explicit path prefixes, + /// non-host path heads, and file-like source-location suffixes are paths. + /// + /// - Parameter rawText: Raw text from a terminal link callback. + /// - Returns: `true` when unresolved text should not be guessed as a URL. + public func isRelativePathReferenceCandidate(_ rawText: String) -> Bool { + let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + let spellings = trimmed.pathResolutionCandidates() + guard !spellings.contains(where: hasExplicitURLScheme) else { return false } + for spelling in spellings { + guard !shouldBypassPathResolutionForURL(spelling) else { continue } + for reference in spelling.terminalPathReferenceCandidates() { + let path = reference.path + guard !(path as NSString).isAbsolutePath else { continue } + if path.hasPrefix("./") || path.hasPrefix("../") || path.hasPrefix("~/") { + return true + } + if reference.line != nil, path.contains(".") { + return true + } + guard let slash = path.firstIndex(of: "/") else { continue } + let head = String(path[.. Bool { + guard let scheme = URL(string: rawText)?.scheme?.lowercased() else { return false } + guard !hasExplicitURLScheme(rawText, parsedScheme: scheme) else { return true } + + // Foundation also parses `File.swift:12` as a custom URL scheme. A + // numeric location suffix on a path-shaped stem remains eligible; an + // arbitrary `scheme:value` stays on the URL route. + let locationReference = rawText.terminalPathReferenceCandidates() + .first { $0.line != nil } + guard let path = locationReference?.path else { return true } + return !path.contains("/") && !path.contains(".") && !path.hasPrefix("~") + } + + private func hasExplicitURLScheme(_ rawText: String) -> Bool { + guard let scheme = URL(string: rawText)?.scheme?.lowercased() else { return false } + return hasExplicitURLScheme(rawText, parsedScheme: scheme) + } + + private func hasExplicitURLScheme(_ rawText: String, parsedScheme: String) -> Bool { + Self.explicitURLSchemes.contains(parsedScheme) || rawText.contains("://") + } + + private func resolutionDirectories( + context: TerminalPathResolutionContext + ) -> [String] { + var directories: [String] = [] + for rawDirectory in [context.workingDirectory].compactMap({ $0 }) + context.fallbackDirectories { + let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let expanded = (trimmed as NSString).expandingTildeInPath + guard expanded.hasPrefix("/") else { continue } + let standardized = (expanded as NSString).standardizingPath + guard !directories.contains(standardized) else { continue } + directories.append(standardized) + } + return directories + } + + private func existingResolution( + path: String, + line: Int?, + column: Int?, + seenPaths: inout Set + ) -> TerminalPathResolution? { + let standardizedPath = (path as NSString).standardizingPath + guard seenPaths.insert(standardizedPath).inserted else { return nil } + guard fileExists(standardizedPath) else { return nil } + return TerminalPathResolution( + path: standardizedPath, + line: line, + column: column + ) } } diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift index a33e867b4cf4..3f4405e7538f 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift @@ -162,22 +162,37 @@ private func existsIn(_ existingPaths: Set) -> @Sendable (String) -> Boo @Test func resolvesRelativePathWithLineSuffix() { let existingFile = "/Users/dev/project/Sources/App/SettingsWindowFactory.swift" - #expect( - TerminalPathResolver(fileExists: existsIn([existingFile])).resolveOpenURLFilePath( + let resolution = TerminalPathResolver(fileExists: existsIn([existingFile])) + .resolveOpenURLFileReference( "Sources/App/SettingsWindowFactory.swift:12", - cwd: "/Users/dev/project" - ) == existingFile - ) + context: TerminalPathResolutionContext(workingDirectory: "/Users/dev/project") + ) + #expect(resolution?.path == existingFile) + #expect(resolution?.line == 12) + #expect(resolution?.column == nil) } @Test func resolvesRelativePathWithLineAndColumnSuffix() { let existingFile = "/Users/dev/project/Sources/App/SettingsWindowFactory.swift" - #expect( - TerminalPathResolver(fileExists: existsIn([existingFile])).resolveOpenURLFilePath( + let resolution = TerminalPathResolver(fileExists: existsIn([existingFile])) + .resolveOpenURLFileReference( "Sources/App/SettingsWindowFactory.swift:12:7", - cwd: "/Users/dev/project" - ) == existingFile - ) + context: TerminalPathResolutionContext(workingDirectory: "/Users/dev/project") + ) + #expect(resolution?.path == existingFile) + #expect(resolution?.line == 12) + #expect(resolution?.column == 7) + } + + @Test func resolvesLocationSuffixBeforeTrailingProsePunctuation() { + let existingFile = "/Users/dev/project/Settings.swift" + let resolution = TerminalPathResolver(fileExists: existsIn([existingFile])) + .resolveOpenURLFileReference( + "Settings.swift:12,", + context: TerminalPathResolutionContext(workingDirectory: "/Users/dev/project") + ) + #expect(resolution?.path == existingFile) + #expect(resolution?.line == 12) } @Test func locationSuffixDoesNotBypassExistenceCheck() { @@ -189,6 +204,97 @@ private func existsIn(_ existingPaths: Set) -> @Sendable (String) -> Boo ) } + @Test func cwdWinsBeforeFallbackRoots() { + let cwdFile = "/Users/dev/project/Sources/Settings.swift" + let repositoryFile = "/Users/dev/project/Settings.swift" + let resolution = TerminalPathResolver(fileExists: existsIn([cwdFile, repositoryFile])) + .resolveOpenURLFileReference( + "Settings.swift:8", + context: TerminalPathResolutionContext( + workingDirectory: "/Users/dev/project/Sources", + fallbackDirectories: ["/Users/dev/project"] + ) + ) + #expect(resolution?.path == cwdFile) + #expect(resolution?.line == 8) + } + + @Test func repositoryRootFallbackHandlesCwdChanges() { + let repositoryFile = "/Users/dev/project/Sources/App/SettingsWindowFactory.swift" + let resolution = TerminalPathResolver(fileExists: existsIn([repositoryFile])) + .resolveOpenURLFileReference( + "Sources/App/SettingsWindowFactory.swift:12:7", + context: TerminalPathResolutionContext( + workingDirectory: "/Users/dev/project/Sources", + fallbackDirectories: ["/Users/dev/project", "/Users/dev"] + ) + ) + #expect(resolution?.path == repositoryFile) + #expect(resolution?.line == 12) + #expect(resolution?.column == 7) + } + + @Test func workspaceRootIsTriedAfterMissingRepositoryCandidate() { + let workspaceFile = "/Users/dev/workspace/Sources/App.swift" + let resolution = TerminalPathResolver(fileExists: existsIn([workspaceFile])) + .resolveOpenURLFileReference( + "Sources/App.swift:4", + context: TerminalPathResolutionContext( + workingDirectory: "/Users/dev/workspace/build", + fallbackDirectories: ["/Users/dev/repository", "/Users/dev/workspace"] + ) + ) + #expect(resolution?.path == workspaceFile) + #expect(resolution?.line == 4) + } + + @Test func literalFileEndingInNumericSuffixWins() { + let literalFile = "/Users/dev/project/report.txt:12" + let strippedFile = "/Users/dev/project/report.txt" + let resolution = TerminalPathResolver(fileExists: existsIn([literalFile, strippedFile])) + .resolveOpenURLFileReference( + "report.txt:12", + context: TerminalPathResolutionContext(workingDirectory: "/Users/dev/project") + ) + #expect(resolution?.path == literalFile) + #expect(resolution?.line == nil) + } + + @Test func zeroLocationSuffixIsNotGuessed() { + let strippedFile = "/Users/dev/project/report.txt" + #expect( + TerminalPathResolver(fileExists: existsIn([strippedFile])) + .resolveOpenURLFileReference( + "report.txt:0", + context: TerminalPathResolutionContext(workingDirectory: "/Users/dev/project") + ) == nil + ) + } + + @Test(arguments: [ + "./scripts/reload.sh", + "../Sources/App.swift:4", + "Sources/App/SettingsWindowFactory.swift:12:7", + "a/b/c", + "s/pipeline-failure-state-model.md", + "File.swift:12", + ]) + func recognizesUnambiguousRelativePathReferences(rawText: String) { + #expect(TerminalPathResolver().isRelativePathReferenceCandidate(rawText)) + } + + @Test(arguments: [ + "https://example.com/path", + "mailto:test@example.com", + "example.com/docs", + "localhost:3000/docs", + "/tmp/App.swift:12", + "foo_bar", + ]) + func doesNotClassifyURLsAbsolutePathsOrOpaqueTokensAsRelativePaths(rawText: String) { + #expect(!TerminalPathResolver().isRelativePathReferenceCandidate(rawText)) + } + @Test func resolvesAbsoluteMarkdownPathWithTrailingDot() { let existingFile = "/Users/dev/project/skills/marketing/data/lawrencecchen-tweets.md" #expect( @@ -222,6 +328,24 @@ private func existsIn(_ existingPaths: Set) -> @Sendable (String) -> Boo cwd: "/tmp" ) == nil ) + #expect( + TerminalPathResolver(fileExists: { _ in true }).resolveOpenURLFilePath( + "https://example.com:443", + cwd: "/tmp" + ) == nil + ) + #expect( + TerminalPathResolver(fileExists: { _ in true }).resolveOpenURLFilePath( + "ssh:host/path:22", + cwd: "/tmp" + ) == nil + ) + #expect( + TerminalPathResolver(fileExists: { _ in true }).resolvePath( + "\"https://example.com/path\"", + context: TerminalPathResolutionContext(workingDirectory: "/tmp") + ) == nil + ) } @Test func schemelessRelativeAndAbsoluteTextStaysEligible() { @@ -260,6 +384,20 @@ private func existsIn(_ existingPaths: Set) -> @Sendable (String) -> Boo #expect(resolution.rawToken == "/tmp/cmux-visible-line.md") } + @Test func visibleLinePreservesSourceLocation() throws { + let existingFile = "/tmp/Sources/App.swift" + let result = try #require( + TerminalPathResolver(fileExists: existsIn([existingFile])).resolveVisibleLineReference( + "error: Sources/App.swift:19:3", + column: 12, + context: TerminalPathResolutionContext(workingDirectory: "/tmp") + ) + ) + #expect(result.resolution.path == existingFile) + #expect(result.resolution.line == 19) + #expect(result.resolution.column == 3) + } + @Test func resolvesShellEscapedTokenSpanningSpaces() throws { let existingFile = "/tmp/cmux visible escaped.md" let line = "cat /tmp/cmux\\ visible\\ escaped.md" diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift index 43e4219cca35..353e4296cdd3 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift @@ -57,9 +57,35 @@ public struct PreferredEditorService: FileOpening { } public func open(_ url: URL) { + open(url, line: nil, column: nil) + } + + /// Opens a file, carrying a one-based source location to editor commands. + /// + /// Configured commands receive one shell-quoted `path:line[:column]` + /// argument. Commands that support source locations can navigate directly; + /// UI-test capture records that same argument, while the system fallback + /// still receives the plain file URL. + /// + /// - Parameters: + /// - url: The local file URL to open. + /// - line: An optional one-based line. + /// - column: An optional one-based column, used only when `line` exists. + public func open(_ url: URL, line: Int?, column: Int?) { + let editorArgument: String + if let line { + if let column { + editorArgument = "\(url.path):\(line):\(column)" + } else { + editorArgument = "\(url.path):\(line)" + } + } else { + editorArgument = url.path + } + if capture.appendLineIfConfigured( envKey: "CMUX_UI_TEST_CAPTURE_OPEN_PATH", - line: url.path + line: editorArgument ) { return } @@ -71,7 +97,7 @@ public struct PreferredEditorService: FileOpening { let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/sh") - process.arguments = ["-c", "\(command) \(url.path.posixShellSingleQuoted)"] + process.arguments = ["-c", "\(command) \(editorArgument.posixShellSingleQuoted)"] process.standardOutput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorServiceTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorServiceTests.swift index c44a457f1410..8db314009180 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorServiceTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorServiceTests.swift @@ -97,6 +97,51 @@ struct PreferredEditorServiceTests { #expect(opener.openedURLs.isEmpty) } + @Test func configuredCommandReceivesLineAndColumnSuffix() async throws { + let scratch = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: scratch) } + let marker = scratch.appendingPathComponent("received-location.txt") + let script = scratch.appendingPathComponent("location-editor.sh") + try #""" + #!/bin/sh + printf %s "$1" > '\#(marker.path)' + """#.write(to: script, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: script.path + ) + + let opener = RecordingSystemOpener() + let service = PreferredEditorService( + editor: FixedEditor(resolvedCommand: script.path), + capture: UITestCaptureSink(environment: [:]), + systemOpener: opener + ) + let fileURL = URL(fileURLWithPath: "/tmp/source file.swift") + + service.open(fileURL, line: 42, column: 7) + + for _ in 0..<200 where !FileManager.default.fileExists(atPath: marker.path) { + try await Task.sleep(for: .milliseconds(25)) + } + let received = try String(contentsOf: marker, encoding: .utf8) + #expect(received == "/tmp/source file.swift:42:7") + #expect(opener.openedURLs.isEmpty) + } + + @Test func systemFallbackReceivesPlainFileURLForSourceLocation() { + let opener = RecordingSystemOpener() + let service = PreferredEditorService( + editor: FixedEditor(resolvedCommand: nil), + capture: UITestCaptureSink(environment: [:]), + systemOpener: opener + ) + let fileURL = URL(fileURLWithPath: "/tmp/source.swift") + + service.open(fileURL, line: 42, column: 7) + + #expect(opener.openedURLs == [fileURL]) + } + @Test func failingCommandFallsBackToSystemOpen() async { let opener = RecordingSystemOpener() let service = PreferredEditorService( diff --git a/Sources/CommandClickFileOpenRouter.swift b/Sources/CommandClickFileOpenRouter.swift index 2d6626574a0d..f2cc83a3382a 100644 --- a/Sources/CommandClickFileOpenRouter.swift +++ b/Sources/CommandClickFileOpenRouter.swift @@ -1,5 +1,7 @@ import AppKit import CmuxSettings +import CmuxTerminalCore +import CmuxWorkspaces import Foundation enum CommandClickFileOpenRouter { @@ -13,18 +15,18 @@ enum CommandClickFileOpenRouter { static func openInCmux( workspace: Workspace, sourcePanelId: UUID, - filePath: String + resolution: TerminalPathResolution ) -> Bool { let store = FileRouteSettingsStore(defaults: .standard) - if store.shouldRouteMarkdown(path: filePath), - workspace.openOrFocusMarkdownSplit(from: sourcePanelId, filePath: filePath) != nil { + if store.shouldRouteMarkdown(path: resolution.path), + workspace.openOrFocusMarkdownSplit(from: sourcePanelId, filePath: resolution.path) != nil { return true } - guard store.shouldRouteSupportedFile(path: filePath) else { + guard store.shouldRouteSupportedFile(path: resolution.path) else { return false } - return workspace.openOrFocusFilePreviewSplit(from: sourcePanelId, filePath: filePath) != nil + return workspace.openOrFocusFilePreviewSplit(from: sourcePanelId, filePath: resolution.path) != nil } /// Resolve the working directory for a terminal surface, preferring the @@ -51,6 +53,69 @@ enum CommandClickFileOpenRouter { return dir.isEmpty ? nil : dir } + /// Build the shared path-resolution context for a terminal surface. + /// + /// The live per-panel directory remains authoritative. If output predates + /// a cwd change, resolution falls back to the tracked repository root and + /// then the workspace directory; the resolver still requires existence at + /// every candidate. + @MainActor + static func resolvePathContext( + workspace: Workspace, + surfaceId: UUID + ) -> TerminalPathResolutionContext { + let workingDirectory = resolveWorkingDirectory( + workspace: workspace, + surfaceId: surfaceId + ) + let standardizedWorkingDirectory = workingDirectory.map { + ($0 as NSString).standardizingPath + } + let fallbackDirectories = [ + workspace.extensionSidebarProjectRootPath, + workspace.terminalPanel(for: surfaceId)?.requestedWorkingDirectory, + workspace.currentDirectory, + ].compactMap { directory -> String? in + guard let directory else { return nil } + let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let standardized = (trimmed as NSString).standardizingPath + guard let standardizedWorkingDirectory else { return standardized } + guard standardizedWorkingDirectory == standardized || + standardizedWorkingDirectory.hasPrefix(standardized + "/") else { + return nil + } + return standardized + } + return TerminalPathResolutionContext( + workingDirectory: workingDirectory, + fallbackDirectories: fallbackDirectories + ) + } + + /// Open a resolved file outside cmux while preserving source locations. + /// + /// The Ghostty callback keeps the historical system opener for ordinary + /// absolute paths. A source location necessarily uses the preferred-editor + /// path so commands that understand `file:line[:column]` can honor it. + @MainActor + @discardableResult + static func openExternally( + _ resolution: TerminalPathResolution, + preferConfiguredEditor: Bool + ) -> Bool { + let fileURL = URL(fileURLWithPath: resolution.path) + guard preferConfiguredEditor || resolution.line != nil else { + return NSWorkspace.shared.open(fileURL) + } + PreferredEditorService(defaults: .standard).open( + fileURL, + line: resolution.line, + column: resolution.column + ) + return true + } + /// Schedule a file open in cmux, deferred to the next runloop tick. /// /// Ghostty's `Surface.openUrl` holds an internal `os_unfair_lock` when it @@ -64,7 +129,7 @@ enum CommandClickFileOpenRouter { workspace: Workspace, preferredWorkspaceId: UUID, surfaceId: UUID, - filePath: String, + resolution: TerminalPathResolution, fallback: (@MainActor @Sendable () -> Void)? = nil ) { DispatchQueue.main.async { @@ -76,14 +141,14 @@ enum CommandClickFileOpenRouter { fallback?() return } - guard shouldRouteInCmux(path: filePath) else { + guard shouldRouteInCmux(path: resolution.path) else { fallback?() return } if openInCmux( workspace: resolvedWorkspace, sourcePanelId: surfaceId, - filePath: filePath + resolution: resolution ) { return } diff --git a/Sources/GhosttyApp+EmbeddedBrowserLink.swift b/Sources/GhosttyApp+EmbeddedBrowserLink.swift new file mode 100644 index 000000000000..c1e963bf471b --- /dev/null +++ b/Sources/GhosttyApp+EmbeddedBrowserLink.swift @@ -0,0 +1,72 @@ +import AppKit +import Foundation + +extension GhosttyApp { + /// Opens a terminal link in a neighboring cmux browser when available. + @MainActor + static func openEmbeddedBrowserLink( + url: URL, + sourceWorkspaceId: UUID, + sourcePanelId: UUID, + host: String + ) -> Bool { + guard BrowserAvailabilitySettings.isEnabled() else { + #if DEBUG + cmuxDebugLog("link.openURL deferred embedded but cmuxBrowser=disabled, opening externally url=\(url)") + #endif + return NSWorkspace.shared.open(url) + } + + guard let app = AppDelegate.shared, + let resolved = app.workspaceContainingPanel( + panelId: sourcePanelId, + preferredWorkspaceId: sourceWorkspaceId + ) else { + #if DEBUG + cmuxDebugLog( + "link.openURL deferred embedded but workspace lookup failed, opening externally " + + "tabId=\(sourceWorkspaceId) surfaceId=\(sourcePanelId) url=\(url)" + ) + #endif + return NSWorkspace.shared.open(url) + } + + let workspace = resolved.workspace + #if DEBUG + if workspace.id != sourceWorkspaceId { + cmuxDebugLog( + "link.openURL workspace.remap sourceTab=\(sourceWorkspaceId) " + + "resolvedTab=\(workspace.id) surfaceId=\(sourcePanelId)" + ) + } + #endif + + let openedInBrowser: Bool + if let targetPane = workspace.preferredRightSideTargetPane(fromPanelId: sourcePanelId) { + #if DEBUG + cmuxDebugLog("link.openURL opening in existing browser pane=\(targetPane)") + #endif + openedInBrowser = workspace.newBrowserSurface(inPane: targetPane, url: url, focus: true) != nil + } else { + #if DEBUG + cmuxDebugLog("link.openURL opening as new browser split from surface=\(sourcePanelId)") + #endif + openedInBrowser = workspace.newBrowserSplit( + from: sourcePanelId, + orientation: .horizontal, + url: url + ) != nil + } + + guard openedInBrowser else { + #if DEBUG + cmuxDebugLog( + "link.openURL deferred embedded browser creation failed, opening externally " + + "host=\(host) url=\(url)" + ) + #endif + return NSWorkspace.shared.open(url) + } + return true + } +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index e85050ef7487..15c90498055f 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -185,24 +185,6 @@ private func cmuxRuntimeReadClipboardCallback( // CmuxTerminalCore). The process-wide instance is the transitional // GhosttyApp.terminalPasteboard composition static below. -/// The app-side conformance injected into ``TerminalLinkRouter``: terminal -/// links validate hosts and resolve bare domains through the same browser -/// rules the embedded browser uses. -struct TerminalBrowserHostNormalizer: BrowserHostNormalizing { - func normalizedHost(_ rawHost: String) -> String? { - BrowserInsecureHTTPSettings.normalizeHost(rawHost) - } - - func navigableWebURL(_ input: String) -> URL? { - resolveBrowserNavigableURL(input) - } -} - -func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? { - TerminalLinkRouter(hostNormalizer: TerminalBrowserHostNormalizer()) - .resolveOpenURLTarget(rawValue) -} - private var terminalKeyboardCopyModeIndicatorText: String { String(localized: "ghostty.copy-mode.indicator", defaultValue: "vim") } @@ -2489,70 +2471,6 @@ class GhosttyApp { } } - @MainActor - private static func openEmbeddedBrowserLink( - url: URL, - sourceWorkspaceId: UUID, - sourcePanelId: UUID, - host: String - ) -> Bool { - guard BrowserAvailabilitySettings.isEnabled() else { - #if DEBUG - cmuxDebugLog("link.openURL deferred embedded but cmuxBrowser=disabled, opening externally url=\(url)") - #endif - return NSWorkspace.shared.open(url) - } - - guard let app = AppDelegate.shared, - let resolved = app.workspaceContainingPanel( - panelId: sourcePanelId, - preferredWorkspaceId: sourceWorkspaceId - ) else { - #if DEBUG - cmuxDebugLog( - "link.openURL deferred embedded but workspace lookup failed, opening externally " + - "tabId=\(sourceWorkspaceId) surfaceId=\(sourcePanelId) url=\(url)" - ) - #endif - return NSWorkspace.shared.open(url) - } - - let workspace = resolved.workspace - #if DEBUG - if workspace.id != sourceWorkspaceId { - cmuxDebugLog( - "link.openURL workspace.remap sourceTab=\(sourceWorkspaceId) " + - "resolvedTab=\(workspace.id) surfaceId=\(sourcePanelId)" - ) - } - #endif - - let openedInBrowser: Bool - if let targetPane = workspace.preferredRightSideTargetPane(fromPanelId: sourcePanelId) { - #if DEBUG - cmuxDebugLog("link.openURL opening in existing browser pane=\(targetPane)") - #endif - openedInBrowser = workspace.newBrowserSurface(inPane: targetPane, url: url, focus: true) != nil - } else { - #if DEBUG - cmuxDebugLog("link.openURL opening as new browser split from surface=\(sourcePanelId)") - #endif - openedInBrowser = workspace.newBrowserSplit(from: sourcePanelId, orientation: .horizontal, url: url) != nil - } - - guard openedInBrowser else { - #if DEBUG - cmuxDebugLog( - "link.openURL deferred embedded browser creation failed, opening externally " + - "host=\(host) url=\(url)" - ) - #endif - return NSWorkspace.shared.open(url) - } - - return true - } - private func splitDirection(from direction: ghostty_action_split_direction_e) -> SplitDirection? { switch direction { case GHOSTTY_SPLIT_DIRECTION_RIGHT: return .right @@ -3049,46 +2967,53 @@ class GhosttyApp { // match path-like text as URLs; route existing local files through cmux first. let trimmedUrlString = urlString.trimmingCharacters(in: .whitespacesAndNewlines) var normalizedOpenURLString = urlString + var resolvedFileReference: TerminalPathResolution? if !trimmedUrlString.isEmpty { - let filePathResolution: (routed: Bool, fallbackPath: String?) = performOnMain { + let filePathResolution: (routed: Bool, resolution: TerminalPathResolution?) = performOnMain { guard let termSurface = surfaceView.terminalSurface, let workspace = termSurface.owningWorkspace(), !workspace.isRemoteTerminalSurface(termSurface.id) else { return (false, nil) } - let cwd = CommandClickFileOpenRouter.resolveWorkingDirectory( + let context = CommandClickFileOpenRouter.resolvePathContext( workspace: workspace, surfaceId: termSurface.id ) - guard let resolvedPath = TerminalPathResolver().resolveOpenURLFilePath(trimmedUrlString, cwd: cwd) else { + guard let resolution = TerminalPathResolver().resolveOpenURLFileReference( + trimmedUrlString, + context: context + ) else { return (false, nil) } - guard CommandClickFileOpenRouter.shouldRouteInCmux(path: resolvedPath) else { - return (false, resolvedPath) + guard CommandClickFileOpenRouter.shouldRouteInCmux(path: resolution.path) else { + return (false, resolution) } #if DEBUG - cmuxDebugLog("link.openURL resolvedAsFilePath=\(resolvedPath)") + cmuxDebugLog("link.openURL resolvedAsFilePath=\(resolution.path)") #endif - let fileURL = URL(fileURLWithPath: resolvedPath) CommandClickFileOpenRouter.deferredOpenFileInCmux( workspace: workspace, preferredWorkspaceId: workspace.id, surfaceId: termSurface.id, - filePath: resolvedPath + resolution: resolution ) { - NSWorkspace.shared.open(fileURL) + CommandClickFileOpenRouter.openExternally( + resolution, + preferConfiguredEditor: false + ) } - return (true, resolvedPath) + return (true, resolution) } - if let fallbackPath = filePathResolution.fallbackPath { - normalizedOpenURLString = fallbackPath + resolvedFileReference = filePathResolution.resolution + if let resolution = filePathResolution.resolution { + normalizedOpenURLString = resolution.path } if filePathResolution.routed { return true } } - - guard let target = resolveTerminalOpenURLTarget(normalizedOpenURLString) else { + if TerminalPathResolver().isRelativePathReferenceCandidate(trimmedUrlString) { return true } + guard let target = TerminalBrowserHostNormalizer().resolveOpenURLTarget(normalizedOpenURLString) else { #if DEBUG cmuxDebugLog("link.openURL resolve failed, returning false") #endif @@ -3102,10 +3027,6 @@ class GhosttyApp { return true } #endif - // Route local file paths into cmux when the file-routing toggle is on. - // Explicit URL schemes (including file://) stay on the URL route so - // the OS owns non-web schemes, while bare paths like `foo.md#L42` - // still route into the viewer when eligible. if TerminalOpenURLFileRoutingPolicy().shouldAttemptCmuxFileRouting( rawOpenURLValue: trimmedUrlString, target: target @@ -3122,7 +3043,11 @@ class GhosttyApp { workspace: workspace, preferredWorkspaceId: workspace.id, surfaceId: termSurface.id, - filePath: fileURL.path + resolution: resolvedFileReference ?? TerminalPathResolution( + path: fileURL.path, + line: nil, + column: nil + ) ) { NSWorkspace.shared.open(fileURL) } @@ -3131,7 +3056,14 @@ class GhosttyApp { if routed { return true } - // Fall through to the existing NSWorkspace path below. + } + if let resolvedFileReference, resolvedFileReference.line != nil { + return performOnMain { + CommandClickFileOpenRouter.openExternally( + resolvedFileReference, + preferConfiguredEditor: false + ) + } } if !BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser() { @@ -3400,18 +3332,20 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } private struct WordPathResolution { - let path: String + let resolution: TerminalPathResolution let source: WordPathResolutionSource let rawToken: String + + var path: String { resolution.path } } private func makeWordPathResolution( - path: String, + resolution: TerminalPathResolution, source: WordPathResolutionSource, rawToken: String ) -> WordPathResolution { WordPathResolution( - path: path, + resolution: resolution, source: source, rawToken: rawToken ) @@ -6579,7 +6513,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { cmuxDebugLog("link.wordFallback resolved=\(resolution.path) source=\(resolution.source.rawValue)") #endif - PreferredEditorService(defaults: .standard).open(URL(fileURLWithPath: resolution.path)) + CommandClickFileOpenRouter.openExternally( + resolution.resolution, + preferConfiguredEditor: true + ) } /// Check if the word under the mouse cursor resolves to an existing file/directory @@ -6595,7 +6532,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let workspace = termSurface.owningWorkspace(), !workspace.isRemoteTerminalSurface(termSurface.id) else { return nil } - guard let cwd = resolvedWordPathWorkingDirectory(workspace: workspace, terminalSurface: termSurface) else { + let context = resolvedWordPathContext(workspace: workspace, terminalSurface: termSurface) + guard context.workingDirectory != nil || !context.fallbackDirectories.isEmpty else { return nil } @@ -6603,7 +6541,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let pointSnapshotResolution = snapshotPoint.flatMap { resolveVisibleWordPath( at: $0, - cwd: cwd, + context: context, workspace: workspace, terminalSurface: termSurface ) @@ -6621,9 +6559,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #else let resolvedQuicklookWord = decodedWord #endif - if let resolvedPath = TerminalPathResolver().resolveQuicklookPath(resolvedQuicklookWord, cwd: cwd) { + if let resolution = TerminalPathResolver().resolvePath( + resolvedQuicklookWord, + context: context + ) { quicklookResolution = makeWordPathResolution( - path: resolvedPath, + resolution: resolution, source: .quicklook, rawToken: resolvedQuicklookWord ) @@ -6640,7 +6581,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif viewportResolution = resolveVisibleWordPathFromViewportOffset( viewportOffsetStart, - cwd: cwd, + context: context, workspace: workspace, terminalSurface: termSurface ) @@ -6754,11 +6695,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif } - private func resolvedWordPathWorkingDirectory( + private func resolvedWordPathContext( workspace: Workspace, terminalSurface: TerminalSurface - ) -> String? { - CommandClickFileOpenRouter.resolveWorkingDirectory( + ) -> TerminalPathResolutionContext { + CommandClickFileOpenRouter.resolvePathContext( workspace: workspace, surfaceId: terminalSurface.id ) @@ -6797,7 +6738,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private func resolveVisibleWordPathFromViewportOffset( _ viewportOffsetStart: Int, - cwd: String, + context: TerminalPathResolutionContext, workspace: Workspace, terminalSurface: TerminalSurface ) -> WordPathResolution? { @@ -6820,24 +6761,24 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard visibleRow >= 0, visibleRow < visibleLines.count else { return nil } let column = max(0, min(cols - 1, viewportOffsetStart % cols)) - guard let resolution = TerminalPathResolver().resolveVisibleLinePath( + guard let result = TerminalPathResolver().resolveVisibleLineReference( visibleLines[visibleRow], column: column, - cwd: cwd + context: context ) else { return nil } return makeWordPathResolution( - path: resolution.path, + resolution: result.resolution, source: .snapshot, - rawToken: resolution.rawToken + rawToken: result.rawToken ) } private func resolveVisibleWordPath( at point: NSPoint, - cwd: String, + context: TerminalPathResolutionContext, workspace: Workspace, terminalSurface: TerminalSurface ) -> WordPathResolution? { @@ -6868,18 +6809,18 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard visibleRow >= 0, visibleRow < visibleLines.count else { return nil } let column = max(0, min(cols - 1, Int((point.x - xInset) / resolvedCellWidth))) - guard let resolution = TerminalPathResolver().resolveVisibleLinePath( + guard let result = TerminalPathResolver().resolveVisibleLineReference( visibleLines[visibleRow], column: column, - cwd: cwd + context: context ) else { return nil } return makeWordPathResolution( - path: resolution.path, + resolution: result.resolution, source: .snapshot, - rawToken: resolution.rawToken + rawToken: result.rawToken ) } @@ -7006,12 +6947,15 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { CommandClickFileOpenRouter.openInCmux( workspace: workspace, sourcePanelId: termSurface.id, - filePath: resolution.path + resolution: resolution.resolution ) { return resolution } - PreferredEditorService(defaults: .standard).open(URL(fileURLWithPath: resolution.path)) + CommandClickFileOpenRouter.openExternally( + resolution.resolution, + preferConfiguredEditor: true + ) return resolution } diff --git a/Sources/TerminalBrowserHostNormalizer.swift b/Sources/TerminalBrowserHostNormalizer.swift new file mode 100644 index 000000000000..2d3925ef33d2 --- /dev/null +++ b/Sources/TerminalBrowserHostNormalizer.swift @@ -0,0 +1,19 @@ +import CmuxTerminalCore +import Foundation + +/// The app-side conformance injected into ``TerminalLinkRouter``: terminal +/// links validate hosts and resolve bare domains through the same browser +/// rules the embedded browser uses. +struct TerminalBrowserHostNormalizer: BrowserHostNormalizing { + func normalizedHost(_ rawHost: String) -> String? { + BrowserInsecureHTTPSettings.normalizeHost(rawHost) + } + + func navigableWebURL(_ input: String) -> URL? { + resolveBrowserNavigableURL(input) + } + + func resolveOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? { + TerminalLinkRouter(hostNormalizer: self).resolveOpenURLTarget(rawValue) + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 1ed5d0665c23..3c5dcfe55d6d 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -802,6 +802,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources F0F0CF0D0000000000000001 /* ForkParentFallbackSessionIndexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F0CF0D0000000000000002 /* ForkParentFallbackSessionIndexTests.swift */; }; F17F00000000000000000003 /* FullWidthTabCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17F00000000000000000004 /* FullWidthTabCommandTests.swift */; }; F17F00000000000000000001 /* FullWidthTabPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17F00000000000000000002 /* FullWidthTabPersistenceTests.swift */; }; + C80970010000000000000002 /* GhosttyApp+EmbeddedBrowserLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = C80970010000000000000001 /* GhosttyApp+EmbeddedBrowserLink.swift */; }; D0B10012A1B2C3D4E5F60001 /* GhosttyApp+SurfaceConfigurationReload.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10013A1B2C3D4E5F60001 /* GhosttyApp+SurfaceConfigurationReload.swift */; }; EC010D01 /* GhosttyApp+TerminalCustomUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC010D02 /* GhosttyApp+TerminalCustomUpload.swift */; }; 7863B0020000000000000002 /* GhosttyBackgroundAndPanelAppearanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7863B0020000000000000001 /* GhosttyBackgroundAndPanelAppearanceTests.swift */; }; @@ -1549,6 +1550,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C7A502000000000000000002 /* TaskManagerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A502000000000000000001 /* TaskManagerWindowController.swift */; }; 7490C00F7490C00F7490C00F /* TaskVMInfoMemoryPressureFootprintSampler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7490D00F7490D00F7490D00F /* TaskVMInfoMemoryPressureFootprintSampler.swift */; }; 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */; }; + C80970010000000000000004 /* TerminalBrowserHostNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C80970010000000000000003 /* TerminalBrowserHostNormalizer.swift */; }; E4D1768B7041CDE4F9A084A4 /* TerminalClearScreenKeepScrollbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B7555E04A04849992547A2 /* TerminalClearScreenKeepScrollbackTests.swift */; }; C2577000A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */; }; C4A100000000000000000001 /* TerminalController+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A100000000000000000002 /* TerminalController+Capabilities.swift */; }; @@ -2685,6 +2687,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F17F00000000000000000004 /* FullWidthTabCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullWidthTabCommandTests.swift; sourceTree = ""; }; F17F00000000000000000002 /* FullWidthTabPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullWidthTabPersistenceTests.swift; sourceTree = ""; }; A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = ""; }; + C80970010000000000000001 /* GhosttyApp+EmbeddedBrowserLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyApp+EmbeddedBrowserLink.swift"; sourceTree = ""; }; D0B10013A1B2C3D4E5F60001 /* GhosttyApp+SurfaceConfigurationReload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyApp+SurfaceConfigurationReload.swift"; sourceTree = ""; }; EC010D02 /* GhosttyApp+TerminalCustomUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyApp+TerminalCustomUpload.swift"; sourceTree = ""; }; 7863B0020000000000000001 /* GhosttyBackgroundAndPanelAppearanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyBackgroundAndPanelAppearanceTests.swift; sourceTree = ""; }; @@ -3413,6 +3416,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C7A502000000000000000001 /* TaskManagerWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerWindowController.swift; sourceTree = ""; }; 7490D00F7490D00F7490D00F /* TaskVMInfoMemoryPressureFootprintSampler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/TaskVMInfoMemoryPressureFootprintSampler.swift; sourceTree = ""; }; 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAndGhosttyTests.swift; sourceTree = ""; }; + C80970010000000000000003 /* TerminalBrowserHostNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalBrowserHostNormalizer.swift; sourceTree = ""; }; F2B7555E04A04849992547A2 /* TerminalClearScreenKeepScrollbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalClearScreenKeepScrollbackTests.swift; sourceTree = ""; }; C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCmdClickUITests.swift; sourceTree = ""; }; C4A100000000000000000002 /* TerminalController+Capabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+Capabilities.swift"; sourceTree = ""; }; @@ -4411,6 +4415,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C4041001000000000000001A /* CommandClickFileOpenRouter.swift */, C40410010000000000000020 /* FileExplorerDoubleClickActionSettings.swift */, A5001015 /* GhosttyTerminalView.swift */, + C80970010000000000000001 /* GhosttyApp+EmbeddedBrowserLink.swift */, + C80970010000000000000003 /* TerminalBrowserHostNormalizer.swift */, 7863A0010000000000000001 /* GhosttyNSView+RendererProfiling.swift */, A11E5E1E0000000000000002 /* TerminalSelectionAccessibilityNotifier.swift */, A5F10000000000000000000A /* GhosttySurfaceScrollView+NotificationScroll.swift */, @@ -6588,6 +6594,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 5E55400000000000000000D1 /* FocusStealingResponderConformances.swift in Sources */, C0DEFB100000000000000001 /* FocusSurfaceBroadcaster.swift in Sources */, AA5269A0C0DE0002FACE0002 /* ForeignFirstResponderPolicy.swift in Sources */, + C80970010000000000000002 /* GhosttyApp+EmbeddedBrowserLink.swift in Sources */, D0B10012A1B2C3D4E5F60001 /* GhosttyApp+SurfaceConfigurationReload.swift in Sources */, EC010D01 /* GhosttyApp+TerminalCustomUpload.swift in Sources */, A7206D010000000000000001 /* GhosttyConfig+AppearanceSync.swift in Sources */, @@ -7069,6 +7076,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C7A506000000000000000002 /* TaskManagerView.swift in Sources */, C7A502000000000000000002 /* TaskManagerWindowController.swift in Sources */, 7490C00F7490C00F7490C00F /* TaskVMInfoMemoryPressureFootprintSampler.swift in Sources */, + C80970010000000000000004 /* TerminalBrowserHostNormalizer.swift in Sources */, C4A100000000000000000001 /* TerminalController+Capabilities.swift in Sources */, C0DE00000000000000000C42 /* TerminalController+ControlAppFocusContext.swift in Sources */, C0DE00000000000000000C82 /* TerminalController+ControlBrowserPanelContext.swift in Sources */, diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index cc5cdaefb7e1..221032383abb 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -5502,7 +5502,7 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { final class TerminalOpenURLTargetResolutionTests: XCTestCase { func testResolvesHTTPSAsEmbeddedBrowser() throws { - let target = try XCTUnwrap(resolveTerminalOpenURLTarget("https://example.com/path?q=1")) + let target = try XCTUnwrap(TerminalBrowserHostNormalizer().resolveOpenURLTarget("https://example.com/path?q=1")) switch target { case let .embeddedBrowser(url): XCTAssertEqual(url.scheme, "https") @@ -5514,7 +5514,7 @@ final class TerminalOpenURLTargetResolutionTests: XCTestCase { } func testResolvesBareDomainAsEmbeddedBrowser() throws { - let target = try XCTUnwrap(resolveTerminalOpenURLTarget("example.com/docs")) + let target = try XCTUnwrap(TerminalBrowserHostNormalizer().resolveOpenURLTarget("example.com/docs")) switch target { case let .embeddedBrowser(url): XCTAssertEqual(url.scheme, "https") @@ -5526,7 +5526,7 @@ final class TerminalOpenURLTargetResolutionTests: XCTestCase { } func testResolvesFileSchemeAsExternal() throws { - let target = try XCTUnwrap(resolveTerminalOpenURLTarget("file:///tmp/cmux.txt")) + let target = try XCTUnwrap(TerminalBrowserHostNormalizer().resolveOpenURLTarget("file:///tmp/cmux.txt")) switch target { case let .external(url): XCTAssertTrue(url.isFileURL) @@ -5537,7 +5537,7 @@ final class TerminalOpenURLTargetResolutionTests: XCTestCase { } func testResolvesAbsolutePathAsExternalFileURL() throws { - let target = try XCTUnwrap(resolveTerminalOpenURLTarget("/tmp/cmux-path.txt")) + let target = try XCTUnwrap(TerminalBrowserHostNormalizer().resolveOpenURLTarget("/tmp/cmux-path.txt")) switch target { case let .external(url): XCTAssertTrue(url.isFileURL) @@ -5548,7 +5548,7 @@ final class TerminalOpenURLTargetResolutionTests: XCTestCase { } func testResolvesNonWebSchemeAsExternal() throws { - let target = try XCTUnwrap(resolveTerminalOpenURLTarget("mailto:test@example.com")) + let target = try XCTUnwrap(TerminalBrowserHostNormalizer().resolveOpenURLTarget("mailto:test@example.com")) switch target { case let .external(url): XCTAssertEqual(url.scheme, "mailto") @@ -5558,7 +5558,7 @@ final class TerminalOpenURLTargetResolutionTests: XCTestCase { } func testResolvesHostlessHTTPSAsExternal() throws { - let target = try XCTUnwrap(resolveTerminalOpenURLTarget("https:///tmp/cmux.txt")) + let target = try XCTUnwrap(TerminalBrowserHostNormalizer().resolveOpenURLTarget("https:///tmp/cmux.txt")) switch target { case let .external(url): XCTAssertEqual(url.scheme, "https") From 2bfc2068406af14df80f5787b63c32ab721b840f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 14 Jul 2026 17:14:53 -0700 Subject: [PATCH 3/6] fix: preserve resolved relative file routing --- .../TerminalPathResolution.swift | 2 +- .../TerminalPathResolutionContext.swift | 2 +- .../PathResolution/TerminalPathResolver.swift | 14 ++++- .../TerminalPathResolverTests.swift | 22 ++++++++ Sources/CommandClickFileOpenRouter.swift | 53 +++++++++++++------ Sources/GhosttyTerminalView.swift | 27 +++++----- 6 files changed, 89 insertions(+), 31 deletions(-) diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolution.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolution.swift index faa4da8c70cb..a9b0abc94d54 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolution.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolution.swift @@ -1,5 +1,5 @@ /// An existing terminal path together with an optional source location. -public struct TerminalPathResolution: Equatable, Sendable { +public nonisolated struct TerminalPathResolution: Equatable, Sendable { /// The standardized absolute path that passed the existence probe. public let path: String diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolutionContext.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolutionContext.swift index 87214b063912..042d4ee63f2c 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolutionContext.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolutionContext.swift @@ -1,5 +1,5 @@ /// Ordered directories used to resolve a relative path emitted by a terminal. -public struct TerminalPathResolutionContext: Equatable, Sendable { +public nonisolated struct TerminalPathResolutionContext: Equatable, Sendable { /// The clicked surface's current working directory. public let workingDirectory: String? diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift index 653ae893e18c..17141eb23aa3 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift @@ -15,7 +15,7 @@ public import Foundation /// file-existence capability is injected at init. Production uses the real /// file system; tests inject a fake probe. This mirrors /// ``TerminalLinkRouter``'s injected `BrowserHostNormalizing` seam. -public struct TerminalPathResolver: Sendable { +public nonisolated struct TerminalPathResolver: Sendable { private static let explicitURLSchemes: Set = [ "file", "ftp", "gemini", "git", "gopher", "http", "https", "ipfs", "ipns", "magnet", "mailto", "news", "ssh", "tel", @@ -187,6 +187,18 @@ public struct TerminalPathResolver: Sendable { return resolvePath(trimmed, context: context) } + /// Whether an unresolved relative-path-shaped callback should be consumed. + /// + /// Existing references must continue through file routing; only a + /// relative-looking token that failed existence-gated resolution is + /// consumed to prevent the browser fallback from guessing it as a URL. + public func shouldConsumeUnresolvedOpenURLPathReference( + _ rawText: String, + resolvedReference: TerminalPathResolution? + ) -> Bool { + resolvedReference == nil && isRelativePathReferenceCandidate(rawText) + } + /// Whether text is unambiguously shaped like a relative path reference. /// /// This classifier is used only after existence-gated resolution fails, so diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift index 3f4405e7538f..8c1edd8c205b 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift @@ -283,6 +283,28 @@ private func existsIn(_ existingPaths: Set) -> @Sendable (String) -> Boo #expect(TerminalPathResolver().isRelativePathReferenceCandidate(rawText)) } + @Test func consumesOnlyUnresolvedRelativeOpenURLReferences() { + let resolver = TerminalPathResolver() + let resolved = TerminalPathResolution( + path: "/Users/dev/project/Sources/App.swift", + line: 12, + column: 7 + ) + + #expect( + resolver.shouldConsumeUnresolvedOpenURLPathReference( + "Sources/App.swift:12:7", + resolvedReference: nil + ) + ) + #expect( + !resolver.shouldConsumeUnresolvedOpenURLPathReference( + "Sources/App.swift:12:7", + resolvedReference: resolved + ) + ) + } + @Test(arguments: [ "https://example.com/path", "mailto:test@example.com", diff --git a/Sources/CommandClickFileOpenRouter.swift b/Sources/CommandClickFileOpenRouter.swift index f2cc83a3382a..5c70deed41cc 100644 --- a/Sources/CommandClickFileOpenRouter.swift +++ b/Sources/CommandClickFileOpenRouter.swift @@ -4,26 +4,47 @@ import CmuxTerminalCore import CmuxWorkspaces import Foundation -enum CommandClickFileOpenRouter { - nonisolated static func shouldRouteInCmux(path: String) -> Bool { - let store = FileRouteSettingsStore(defaults: .standard) - return store.shouldRouteMarkdown(path: path) - || store.shouldRouteSupportedFile(path: path) +/// Coordinates command-click file routing with injected settings and opener dependencies. +@MainActor +struct CommandClickFileOpenRouter { + private let routeSettings: any FileRouteSettingsReading + private let openExternal: @MainActor @Sendable (URL, Int?, Int?) -> Void + + init( + routeSettings: any FileRouteSettingsReading, + openExternal: @escaping @MainActor @Sendable (URL, Int?, Int?) -> Void + ) { + self.routeSettings = routeSettings + self.openExternal = openExternal + } + + init(defaults: UserDefaults) { + let externalOpener = PreferredEditorService(defaults: defaults) + self.init( + routeSettings: FileRouteSettingsStore(defaults: defaults), + openExternal: { url, line, column in + externalOpener.open(url, line: line, column: column) + } + ) + } + + func shouldRouteInCmux(path: String) -> Bool { + routeSettings.shouldRouteMarkdown(path: path) + || routeSettings.shouldRouteSupportedFile(path: path) } @MainActor - static func openInCmux( + func openInCmux( workspace: Workspace, sourcePanelId: UUID, resolution: TerminalPathResolution ) -> Bool { - let store = FileRouteSettingsStore(defaults: .standard) - if store.shouldRouteMarkdown(path: resolution.path), + if routeSettings.shouldRouteMarkdown(path: resolution.path), workspace.openOrFocusMarkdownSplit(from: sourcePanelId, filePath: resolution.path) != nil { return true } - guard store.shouldRouteSupportedFile(path: resolution.path) else { + guard routeSettings.shouldRouteSupportedFile(path: resolution.path) else { return false } return workspace.openOrFocusFilePreviewSplit(from: sourcePanelId, filePath: resolution.path) != nil @@ -33,7 +54,7 @@ enum CommandClickFileOpenRouter { /// per-panel directory, then the panel's requested working directory, /// then the workspace-level directory. @MainActor - static func resolveWorkingDirectory( + private func resolveWorkingDirectory( workspace: Workspace, surfaceId: UUID ) -> String? { @@ -60,7 +81,7 @@ enum CommandClickFileOpenRouter { /// then the workspace directory; the resolver still requires existence at /// every candidate. @MainActor - static func resolvePathContext( + func resolvePathContext( workspace: Workspace, surfaceId: UUID ) -> TerminalPathResolutionContext { @@ -100,7 +121,7 @@ enum CommandClickFileOpenRouter { /// path so commands that understand `file:line[:column]` can honor it. @MainActor @discardableResult - static func openExternally( + func openExternally( _ resolution: TerminalPathResolution, preferConfiguredEditor: Bool ) -> Bool { @@ -108,10 +129,10 @@ enum CommandClickFileOpenRouter { guard preferConfiguredEditor || resolution.line != nil else { return NSWorkspace.shared.open(fileURL) } - PreferredEditorService(defaults: .standard).open( + openExternal( fileURL, - line: resolution.line, - column: resolution.column + resolution.line, + resolution.column ) return true } @@ -125,7 +146,7 @@ enum CommandClickFileOpenRouter { /// at dispatch time (TOCTOU). When routing fails, `fallback` is called so /// the caller can open the file externally. @MainActor - static func deferredOpenFileInCmux( + func deferredOpenFileInCmux( workspace: Workspace, preferredWorkspaceId: UUID, surfaceId: UUID, diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 15c90498055f..c5364e8ed862 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2975,7 +2975,8 @@ class GhosttyApp { !workspace.isRemoteTerminalSurface(termSurface.id) else { return (false, nil) } - let context = CommandClickFileOpenRouter.resolvePathContext( + let fileOpenRouter = CommandClickFileOpenRouter(defaults: .standard) + let context = fileOpenRouter.resolvePathContext( workspace: workspace, surfaceId: termSurface.id ) @@ -2985,19 +2986,19 @@ class GhosttyApp { ) else { return (false, nil) } - guard CommandClickFileOpenRouter.shouldRouteInCmux(path: resolution.path) else { + guard fileOpenRouter.shouldRouteInCmux(path: resolution.path) else { return (false, resolution) } #if DEBUG cmuxDebugLog("link.openURL resolvedAsFilePath=\(resolution.path)") #endif - CommandClickFileOpenRouter.deferredOpenFileInCmux( + fileOpenRouter.deferredOpenFileInCmux( workspace: workspace, preferredWorkspaceId: workspace.id, surfaceId: termSurface.id, resolution: resolution ) { - CommandClickFileOpenRouter.openExternally( + fileOpenRouter.openExternally( resolution, preferConfiguredEditor: false ) @@ -3012,7 +3013,7 @@ class GhosttyApp { return true } } - if TerminalPathResolver().isRelativePathReferenceCandidate(trimmedUrlString) { return true } + if TerminalPathResolver().shouldConsumeUnresolvedOpenURLPathReference(trimmedUrlString, resolvedReference: resolvedFileReference) { return true } guard let target = TerminalBrowserHostNormalizer().resolveOpenURLTarget(normalizedOpenURLString) else { #if DEBUG cmuxDebugLog("link.openURL resolve failed, returning false") @@ -3033,13 +3034,14 @@ class GhosttyApp { ) { let fileURL = target.url let routed: Bool = performOnMain { + let fileOpenRouter = CommandClickFileOpenRouter(defaults: .standard) guard let termSurface = surfaceView.terminalSurface, let workspace = termSurface.owningWorkspace(), !workspace.isRemoteTerminalSurface(termSurface.id), - CommandClickFileOpenRouter.shouldRouteInCmux(path: fileURL.path) else { + fileOpenRouter.shouldRouteInCmux(path: fileURL.path) else { return false } - CommandClickFileOpenRouter.deferredOpenFileInCmux( + fileOpenRouter.deferredOpenFileInCmux( workspace: workspace, preferredWorkspaceId: workspace.id, surfaceId: termSurface.id, @@ -3059,7 +3061,7 @@ class GhosttyApp { } if let resolvedFileReference, resolvedFileReference.line != nil { return performOnMain { - CommandClickFileOpenRouter.openExternally( + CommandClickFileOpenRouter(defaults: .standard).openExternally( resolvedFileReference, preferConfiguredEditor: false ) @@ -6513,7 +6515,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { cmuxDebugLog("link.wordFallback resolved=\(resolution.path) source=\(resolution.source.rawValue)") #endif - CommandClickFileOpenRouter.openExternally( + CommandClickFileOpenRouter(defaults: .standard).openExternally( resolution.resolution, preferConfiguredEditor: true ) @@ -6699,7 +6701,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { workspace: Workspace, terminalSurface: TerminalSurface ) -> TerminalPathResolutionContext { - CommandClickFileOpenRouter.resolvePathContext( + CommandClickFileOpenRouter(defaults: .standard).resolvePathContext( workspace: workspace, surfaceId: terminalSurface.id ) @@ -6941,10 +6943,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // path on the main thread for a remote workspace. When the cmux route // is applicable but split creation fails, fall back to the preferred // editor so the click never silently no-ops. + let fileOpenRouter = CommandClickFileOpenRouter(defaults: .standard) if let termSurface = terminalSurface, let workspace = termSurface.owningWorkspace(), !workspace.isRemoteTerminalSurface(termSurface.id), - CommandClickFileOpenRouter.openInCmux( + fileOpenRouter.openInCmux( workspace: workspace, sourcePanelId: termSurface.id, resolution: resolution.resolution @@ -6952,7 +6955,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return resolution } - CommandClickFileOpenRouter.openExternally( + fileOpenRouter.openExternally( resolution.resolution, preferConfiguredEditor: true ) From 6945f8903c1f879aa78c7d3b4719860cb5f716ba Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 14 Jul 2026 17:46:48 -0700 Subject: [PATCH 4/6] fix: preserve link and editor semantics --- .../PathResolution/TerminalPathResolver.swift | 17 ++--- .../TerminalPathResolverTests.swift | 16 ++++- .../PreferredEditorLaunchCommand.swift | 67 +++++++++++++++++++ .../FileOpen/PreferredEditorService.swift | 17 +++-- .../PreferredEditorServiceTests.swift | 32 ++++++++- Sources/CommandClickFileOpenRouter.swift | 16 +++-- Sources/GhosttyTerminalView.swift | 16 ++--- cmuxTests/TerminalAndGhosttyTests.swift | 40 +++++++++++ 8 files changed, 188 insertions(+), 33 deletions(-) create mode 100644 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorLaunchCommand.swift diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift index 17141eb23aa3..8e52ea3c8992 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift @@ -224,11 +224,6 @@ public nonisolated struct TerminalPathResolver: Sendable { if reference.line != nil, path.contains(".") { return true } - guard let slash = path.firstIndex(of: "/") else { continue } - let head = String(path[.. Bool { diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift index 8c1edd8c205b..bd49028993af 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift @@ -271,12 +271,21 @@ private func existsIn(_ existingPaths: Set) -> @Sendable (String) -> Boo ) } + @Test func resolvesExtensionlessFilenameWithLineSuffix() { + let existingFile = "/Users/dev/project/Makefile" + let resolution = TerminalPathResolver(fileExists: existsIn([existingFile])) + .resolveOpenURLFileReference( + "Makefile:12", + context: TerminalPathResolutionContext(workingDirectory: "/Users/dev/project") + ) + + #expect(resolution == TerminalPathResolution(path: existingFile, line: 12, column: nil)) + } + @Test(arguments: [ "./scripts/reload.sh", "../Sources/App.swift:4", "Sources/App/SettingsWindowFactory.swift:12:7", - "a/b/c", - "s/pipeline-failure-state-model.md", "File.swift:12", ]) func recognizesUnambiguousRelativePathReferences(rawText: String) { @@ -310,6 +319,9 @@ private func existsIn(_ existingPaths: Set) -> @Sendable (String) -> Boo "mailto:test@example.com", "example.com/docs", "localhost:3000/docs", + "go/docs", + "a/b/c", + "s/pipeline-failure-state-model.md", "/tmp/App.swift:12", "foo_bar", ]) diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorLaunchCommand.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorLaunchCommand.swift new file mode 100644 index 000000000000..0c043987e7c3 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorLaunchCommand.swift @@ -0,0 +1,67 @@ +import Foundation + +/// A shell command rendered for one preferred-editor file open. +/// +/// Source locations are emitted only for editor CLIs whose argument contract +/// is known. Unknown commands receive the plain file path so cmux never asks +/// an editor to open a nonexistent colon-suffixed filename. +nonisolated struct PreferredEditorLaunchCommand: Equatable, Sendable { + let command: String + private let executableName: String? + + init(command: String) { + self.command = command + executableName = preferredEditorExecutableName(command: command) + } + + var supportsSourceLocation: Bool { + guard let executableName else { return false } + return [ + "code", "code-insiders", "codium", "cursor", "subl", "zed", "zed-preview", + ].contains(executableName) + } + + func shellCommand(url: URL, line: Int?, column: Int?) -> String { + let path = url.path + let arguments: [String] + if let line, supportsSourceLocation { + let location = column.map { "\(path):\(line):\($0)" } ?? "\(path):\(line)" + if usesGotoFlag { + arguments = hasGotoFlag + ? [location] + : ["--goto", location] + } else { + arguments = [location] + } + } else { + arguments = [path] + } + + return ([command] + arguments.map(\.posixShellSingleQuoted)) + .joined(separator: " ") + } + + private var usesGotoFlag: Bool { + guard let executableName else { return false } + return ["code", "code-insiders", "codium", "cursor"].contains(executableName) + } + + private var hasGotoFlag: Bool { + command.split(whereSeparator: \Character.isWhitespace).contains { token in + token == "-g" || token == "--goto" + } + } +} + +private func preferredEditorExecutableName(command: String) -> String? { + for rawToken in command.split(whereSeparator: \Character.isWhitespace) { + let token = rawToken.trimmingCharacters(in: CharacterSet(charactersIn: "'\"")) + guard !token.isEmpty else { continue } + let name = (token as NSString).lastPathComponent.lowercased() + if name == "env" || (!token.contains("/") && token.contains("=")) { + continue + } + return name + } + return nil +} diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift index 353e4296cdd3..8705a22b3eb1 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift @@ -60,12 +60,17 @@ public struct PreferredEditorService: FileOpening { open(url, line: nil, column: nil) } + /// Whether the configured editor has a known source-location CLI contract. + public var supportsSourceLocations: Bool { + guard let command = editor.resolvedCommand else { return false } + return PreferredEditorLaunchCommand(command: command).supportsSourceLocation + } + /// Opens a file, carrying a one-based source location to editor commands. /// - /// Configured commands receive one shell-quoted `path:line[:column]` - /// argument. Commands that support source locations can navigate directly; - /// UI-test capture records that same argument, while the system fallback - /// still receives the plain file URL. + /// Known editor CLIs receive their source-location syntax; other commands + /// receive the plain file path. UI-test capture records the structured + /// reference, while the system fallback still receives the plain file URL. /// /// - Parameters: /// - url: The local file URL to open. @@ -95,9 +100,11 @@ public struct PreferredEditorService: FileOpening { return } + let launchCommand = PreferredEditorLaunchCommand(command: command) + let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/sh") - process.arguments = ["-c", "\(command) \(editorArgument.posixShellSingleQuoted)"] + process.arguments = ["-c", launchCommand.shellCommand(url: url, line: line, column: column)] process.standardOutput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorServiceTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorServiceTests.swift index 8db314009180..7af57274a0fc 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorServiceTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorServiceTests.swift @@ -97,7 +97,7 @@ struct PreferredEditorServiceTests { #expect(opener.openedURLs.isEmpty) } - @Test func configuredCommandReceivesLineAndColumnSuffix() async throws { + @Test func genericConfiguredCommandReceivesPlainPathForSourceLocation() async throws { let scratch = try makeScratchDirectory() defer { try? FileManager.default.removeItem(at: scratch) } let marker = scratch.appendingPathComponent("received-location.txt") @@ -124,10 +124,38 @@ struct PreferredEditorServiceTests { try await Task.sleep(for: .milliseconds(25)) } let received = try String(contentsOf: marker, encoding: .utf8) - #expect(received == "/tmp/source file.swift:42:7") + #expect(received == "/tmp/source file.swift") #expect(opener.openedURLs.isEmpty) } + @Test func visualStudioCodeCommandUsesGotoForSourceLocation() { + let command = PreferredEditorLaunchCommand(command: "code -w") + + #expect( + command.shellCommand( + url: URL(fileURLWithPath: "/tmp/source file.swift"), + line: 42, + column: 7 + ) == "code -w '--goto' '/tmp/source file.swift:42:7'" + ) + } + + @Test func colonLocationEditorsReceiveLocationWithoutGotoFlag() { + let command = PreferredEditorLaunchCommand(command: "zed") + + #expect( + command.shellCommand( + url: URL(fileURLWithPath: "/tmp/App.swift"), + line: 9, + column: nil + ) == "zed '/tmp/App.swift:9'" + ) + } + + @Test func unknownEditorDoesNotClaimSourceLocationSupport() { + #expect(!PreferredEditorLaunchCommand(command: "/tmp/editor.sh").supportsSourceLocation) + } + @Test func systemFallbackReceivesPlainFileURLForSourceLocation() { let opener = RecordingSystemOpener() let service = PreferredEditorService( diff --git a/Sources/CommandClickFileOpenRouter.swift b/Sources/CommandClickFileOpenRouter.swift index 5c70deed41cc..c2d0f6a7cef2 100644 --- a/Sources/CommandClickFileOpenRouter.swift +++ b/Sources/CommandClickFileOpenRouter.swift @@ -8,13 +8,16 @@ import Foundation @MainActor struct CommandClickFileOpenRouter { private let routeSettings: any FileRouteSettingsReading + private let supportsExternalLocations: @MainActor @Sendable () -> Bool private let openExternal: @MainActor @Sendable (URL, Int?, Int?) -> Void init( routeSettings: any FileRouteSettingsReading, + supportsExternalLocations: @escaping @MainActor @Sendable () -> Bool, openExternal: @escaping @MainActor @Sendable (URL, Int?, Int?) -> Void ) { self.routeSettings = routeSettings + self.supportsExternalLocations = supportsExternalLocations self.openExternal = openExternal } @@ -22,15 +25,19 @@ struct CommandClickFileOpenRouter { let externalOpener = PreferredEditorService(defaults: defaults) self.init( routeSettings: FileRouteSettingsStore(defaults: defaults), + supportsExternalLocations: { externalOpener.supportsSourceLocations }, openExternal: { url, line, column in externalOpener.open(url, line: line, column: column) } ) } - func shouldRouteInCmux(path: String) -> Bool { - routeSettings.shouldRouteMarkdown(path: path) - || routeSettings.shouldRouteSupportedFile(path: path) + func shouldRouteInCmux(resolution: TerminalPathResolution) -> Bool { + if resolution.line != nil, supportsExternalLocations() { + return false + } + return routeSettings.shouldRouteMarkdown(path: resolution.path) + || routeSettings.shouldRouteSupportedFile(path: resolution.path) } @MainActor @@ -39,6 +46,7 @@ struct CommandClickFileOpenRouter { sourcePanelId: UUID, resolution: TerminalPathResolution ) -> Bool { + guard shouldRouteInCmux(resolution: resolution) else { return false } if routeSettings.shouldRouteMarkdown(path: resolution.path), workspace.openOrFocusMarkdownSplit(from: sourcePanelId, filePath: resolution.path) != nil { return true @@ -162,7 +170,7 @@ struct CommandClickFileOpenRouter { fallback?() return } - guard shouldRouteInCmux(path: resolution.path) else { + guard shouldRouteInCmux(resolution: resolution) else { fallback?() return } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index c5364e8ed862..d2f83735a89f 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2986,7 +2986,7 @@ class GhosttyApp { ) else { return (false, nil) } - guard fileOpenRouter.shouldRouteInCmux(path: resolution.path) else { + guard fileOpenRouter.shouldRouteInCmux(resolution: resolution) else { return (false, resolution) } #if DEBUG @@ -3013,7 +3013,10 @@ class GhosttyApp { return true } } - if TerminalPathResolver().shouldConsumeUnresolvedOpenURLPathReference(trimmedUrlString, resolvedReference: resolvedFileReference) { return true } + if TerminalPathResolver().shouldConsumeUnresolvedOpenURLPathReference( + trimmedUrlString, + resolvedReference: resolvedFileReference + ) { return true } guard let target = TerminalBrowserHostNormalizer().resolveOpenURLTarget(normalizedOpenURLString) else { #if DEBUG cmuxDebugLog("link.openURL resolve failed, returning false") @@ -3034,22 +3037,19 @@ class GhosttyApp { ) { let fileURL = target.url let routed: Bool = performOnMain { + let resolution = resolvedFileReference ?? TerminalPathResolution(path: fileURL.path, line: nil, column: nil) let fileOpenRouter = CommandClickFileOpenRouter(defaults: .standard) guard let termSurface = surfaceView.terminalSurface, let workspace = termSurface.owningWorkspace(), !workspace.isRemoteTerminalSurface(termSurface.id), - fileOpenRouter.shouldRouteInCmux(path: fileURL.path) else { + fileOpenRouter.shouldRouteInCmux(resolution: resolution) else { return false } fileOpenRouter.deferredOpenFileInCmux( workspace: workspace, preferredWorkspaceId: workspace.id, surfaceId: termSurface.id, - resolution: resolvedFileReference ?? TerminalPathResolution( - path: fileURL.path, - line: nil, - column: nil - ) + resolution: resolution ) { NSWorkspace.shared.open(fileURL) } diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 221032383abb..2d320b467d83 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -5525,6 +5525,46 @@ final class TerminalOpenURLTargetResolutionTests: XCTestCase { } } + func testResolvesSingleLabelHostAsEmbeddedBrowser() throws { + let target = try XCTUnwrap(TerminalBrowserHostNormalizer().resolveOpenURLTarget("go/docs")) + switch target { + case let .embeddedBrowser(url): + XCTAssertEqual(url.host, "go") + XCTAssertEqual(url.path, "/docs") + default: + XCTFail("Expected a single-label internal host to remain browser-routable") + } + } + + @MainActor + func testLocationCapableEditorBypassesPreviewButPlainOpenerDoesNot() throws { + let suiteName = "cmux-tests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set(true, forKey: "openSupportedFilesInCmux") + + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-location-routing-\(UUID().uuidString).swift") + try "let value = 1\n".write(to: fileURL, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: fileURL) } + let resolution = TerminalPathResolution(path: fileURL.path, line: 1, column: 5) + let routeSettings = FileRouteSettingsStore(defaults: defaults) + + let locationRouter = CommandClickFileOpenRouter( + routeSettings: routeSettings, + supportsExternalLocations: { true }, + openExternal: { _, _, _ in } + ) + XCTAssertFalse(locationRouter.shouldRouteInCmux(resolution: resolution)) + + let plainRouter = CommandClickFileOpenRouter( + routeSettings: routeSettings, + supportsExternalLocations: { false }, + openExternal: { _, _, _ in } + ) + XCTAssertTrue(plainRouter.shouldRouteInCmux(resolution: resolution)) + } + func testResolvesFileSchemeAsExternal() throws { let target = try XCTUnwrap(TerminalBrowserHostNormalizer().resolveOpenURLTarget("file:///tmp/cmux.txt")) switch target { From 41228c33b59fc54f535c2ec38b795be92ecfe939 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 14 Jul 2026 18:18:53 -0700 Subject: [PATCH 5/6] fix: harden command-click file ownership --- .../TerminalCommandClickFallbackPolicy.swift | 21 +++++ .../PathResolution/TerminalPathResolver.swift | 19 ++++ ...minalCommandClickFallbackPolicyTests.swift | 50 +++++++++++ .../TerminalPathResolverTests.swift | 14 +++ .../PreferredEditorLaunchCommand.swift | 90 +++++++++++++++---- .../PreferredEditorServiceTests.swift | 9 ++ Sources/GhosttyTerminalView.swift | 85 +++++++++--------- 7 files changed, 228 insertions(+), 60 deletions(-) create mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalCommandClickFallbackPolicy.swift create mode 100644 Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalCommandClickFallbackPolicyTests.swift diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalCommandClickFallbackPolicy.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalCommandClickFallbackPolicy.swift new file mode 100644 index 000000000000..2ce836e96c6d --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalCommandClickFallbackPolicy.swift @@ -0,0 +1,21 @@ +/// Decides whether cmux should run its visible-line command-click fallback. +/// +/// Ghostty may consume a mouse release without dispatching a usable link, so +/// snapshot resolution remains a fallback for consumed releases. When the +/// synchronous open-URL callback already owned the same resolved file, the +/// fallback is suppressed to prevent opening it twice. +public nonisolated struct TerminalCommandClickFallbackPolicy: Sendable { + /// Creates the command-click fallback policy. + public init() {} + + /// Returns whether the resolved visible-line path should be opened. + public func shouldOpenFallback( + ghosttyConsumed: Bool, + isSnapshotResolution: Bool, + resolvedPath: String, + handledOpenURLPath: String? + ) -> Bool { + guard handledOpenURLPath != resolvedPath else { return false } + return !ghosttyConsumed || isSnapshotResolution + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift index 8e52ea3c8992..c2fa87cd20b0 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift @@ -230,6 +230,7 @@ public nonisolated struct TerminalPathResolver: Sendable { } private func shouldBypassPathResolutionForURL(_ rawText: String) -> Bool { + if isHostPortReference(rawText) { return true } guard let scheme = URL(string: rawText)?.scheme?.lowercased() else { return false } guard !hasExplicitURLScheme(rawText, parsedScheme: scheme) else { return true } @@ -240,6 +241,24 @@ public nonisolated struct TerminalPathResolver: Sendable { return !rawText.terminalPathReferenceCandidates().contains { $0.line != nil } } + private func isHostPortReference(_ rawText: String) -> Bool { + guard let separator = rawText.lastIndex(of: ":"), + let port = Int(rawText[rawText.index(after: separator)...]), + (0...65_535).contains(port) else { + return false + } + let host = String(rawText[.. Bool { guard let scheme = URL(string: rawText)?.scheme?.lowercased() else { return false } return hasExplicitURLScheme(rawText, parsedScheme: scheme) diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalCommandClickFallbackPolicyTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalCommandClickFallbackPolicyTests.swift new file mode 100644 index 000000000000..3a6365265e61 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalCommandClickFallbackPolicyTests.swift @@ -0,0 +1,50 @@ +import Testing +import CmuxTerminalCore + +@Suite struct TerminalCommandClickFallbackPolicyTests { + private let policy = TerminalCommandClickFallbackPolicy() + + @Test func suppressesFallbackAfterSamePathOpenURLHandling() { + #expect( + !policy.shouldOpenFallback( + ghosttyConsumed: true, + isSnapshotResolution: true, + resolvedPath: "/repo/Sources/App.swift", + handledOpenURLPath: "/repo/Sources/App.swift" + ) + ) + } + + @Test func preservesSnapshotFallbackForMismatchedGhosttyTarget() { + #expect( + policy.shouldOpenFallback( + ghosttyConsumed: true, + isSnapshotResolution: true, + resolvedPath: "/repo/Sources/Wrapped.swift", + handledOpenURLPath: "/repo/Sources/Other.swift" + ) + ) + } + + @Test func consumedNonSnapshotWithoutHandledPathStaysSuppressed() { + #expect( + !policy.shouldOpenFallback( + ghosttyConsumed: true, + isSnapshotResolution: false, + resolvedPath: "/repo/Sources/App.swift", + handledOpenURLPath: nil + ) + ) + } + + @Test func unconsumedResolutionWithoutHandledPathStillOpens() { + #expect( + policy.shouldOpenFallback( + ghosttyConsumed: false, + isSnapshotResolution: false, + resolvedPath: "/repo/Sources/App.swift", + handledOpenURLPath: nil + ) + ) + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift index bd49028993af..254bac34409d 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift @@ -282,6 +282,20 @@ private func existsIn(_ existingPaths: Set) -> @Sendable (String) -> Boo #expect(resolution == TerminalPathResolution(path: existingFile, line: 12, column: nil)) } + @Test(arguments: [ + "localhost:3000", "127.0.0.1:3000", "192.168.1.20:8080", + "0.0.0.0:0", "[::1]:3000", + ]) + func hostPortReferencesBypassExistenceProbing(rawText: String) { + let resolution = TerminalPathResolver(fileExists: { _ in true }) + .resolveOpenURLFileReference( + rawText, + context: TerminalPathResolutionContext(workingDirectory: "/Users/dev/project") + ) + #expect(resolution == nil) + #expect(!TerminalPathResolver().isRelativePathReferenceCandidate(rawText)) + } + @Test(arguments: [ "./scripts/reload.sh", "../Sources/App.swift:4", diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorLaunchCommand.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorLaunchCommand.swift index 0c043987e7c3..a28eb47c9598 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorLaunchCommand.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorLaunchCommand.swift @@ -1,17 +1,15 @@ import Foundation -/// A shell command rendered for one preferred-editor file open. +/// A preferred-editor command with shell-aware executable recognition. /// /// Source locations are emitted only for editor CLIs whose argument contract /// is known. Unknown commands receive the plain file path so cmux never asks /// an editor to open a nonexistent colon-suffixed filename. nonisolated struct PreferredEditorLaunchCommand: Equatable, Sendable { let command: String - private let executableName: String? init(command: String) { self.command = command - executableName = preferredEditorExecutableName(command: command) } var supportsSourceLocation: Bool { @@ -27,9 +25,7 @@ nonisolated struct PreferredEditorLaunchCommand: Equatable, Sendable { if let line, supportsSourceLocation { let location = column.map { "\(path):\(line):\($0)" } ?? "\(path):\(line)" if usesGotoFlag { - arguments = hasGotoFlag - ? [location] - : ["--goto", location] + arguments = hasGotoFlag ? [location] : ["--goto", location] } else { arguments = [location] } @@ -47,21 +43,81 @@ nonisolated struct PreferredEditorLaunchCommand: Equatable, Sendable { } private var hasGotoFlag: Bool { - command.split(whereSeparator: \Character.isWhitespace).contains { token in + shellWords.contains { token in token == "-g" || token == "--goto" } } -} -private func preferredEditorExecutableName(command: String) -> String? { - for rawToken in command.split(whereSeparator: \Character.isWhitespace) { - let token = rawToken.trimmingCharacters(in: CharacterSet(charactersIn: "'\"")) - guard !token.isEmpty else { continue } - let name = (token as NSString).lastPathComponent.lowercased() - if name == "env" || (!token.contains("/") && token.contains("=")) { - continue + private var executableName: String? { + let words = shellWords + var index = 0 + while index < words.count, isEnvironmentAssignment(words[index]) { + index += 1 + } + guard index < words.count else { return nil } + + if (words[index] as NSString).lastPathComponent.lowercased() == "env" { + index += 1 + while index < words.count { + let word = words[index] + if word == "-u" || word == "--unset" { + index += 2 + } else if word.hasPrefix("--unset=") || + word.hasPrefix("-") || + isEnvironmentAssignment(word) { + index += 1 + } else { + break + } + } + } + + guard index < words.count else { return nil } + return (words[index] as NSString).lastPathComponent.lowercased() + } + + private var shellWords: [String] { + var words: [String] = [] + var current = "" + var quote: Character? + var escaping = false + + func finishWord() { + guard !current.isEmpty else { return } + words.append(current) + current.removeAll(keepingCapacity: true) + } + + for character in command { + if escaping { + current.append(character) + escaping = false + } else if character == "\\", quote != "'" { + escaping = true + } else if let activeQuote = quote { + if character == activeQuote { + quote = nil + } else { + current.append(character) + } + } else if character == "'" || character == "\"" { + quote = character + } else if character.isWhitespace { + finishWord() + } else { + current.append(character) + } + } + + if escaping { current.append("\\") } + finishWord() + return words + } + + private func isEnvironmentAssignment(_ word: String) -> Bool { + guard let equals = word.firstIndex(of: "="), equals != word.startIndex else { return false } + return word[.. = [] private var imeConsumedKeyUps: Set = [] private var keyboardCopyModeInputState = TerminalKeyboardCopyModeInputState() @@ -6501,30 +6507,26 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { hasPendingLeftMouseRelease = false guard let surface else { return false } let point = convert(event.locationInWindow, from: nil) - let consumed = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mouseModsFromEvent(event)) - _ = handleCommandClickRelease(at: point, modifierFlags: event.modifierFlags, ghosttyConsumed: consumed) + let release = dispatchLeftMouseRelease(surface: surface, mods: mouseModsFromEvent(event)) + _ = handleCommandClickRelease(at: point, modifierFlags: event.modifierFlags, ghosttyConsumed: release.consumed, handledOpenURLPath: release.handledOpenURLPath) return true } - /// Attempt to open the word under the mouse cursor as a file path, resolved - /// against the terminal panel's current working directory. - private func tryOpenWordAsPath(at point: NSPoint? = nil) { - guard let resolution = resolveWordUnderCursorPath(at: point) else { return } - - #if DEBUG - cmuxDebugLog("link.wordFallback resolved=\(resolution.path) source=\(resolution.source.rawValue)") - #endif - - CommandClickFileOpenRouter(defaults: .standard).openExternally( - resolution.resolution, - preferConfiguredEditor: true - ) + fileprivate func noteHandledOpenURLFile(path: String) { handledOpenURLFilePathForMouseRelease = path } + fileprivate func openURLExternally(_ url: URL, resolvedFilePath: String?) -> Bool { + let handled = NSWorkspace.shared.open(url) + if handled, let path = resolvedFilePath ?? (url.isFileURL ? url.path : nil) { noteHandledOpenURLFile(path: path) } + return handled } - /// Check if the word under the mouse cursor resolves to an existing file/directory - /// in the terminal panel's CWD. Returns the resolved absolute path, or nil. - private func resolveWordUnderCursorAsPath(at point: NSPoint? = nil) -> String? { - resolveWordUnderCursorPath(at: point)?.path + private func dispatchLeftMouseRelease( + surface: ghostty_surface_t, + mods: ghostty_input_mods_e + ) -> (consumed: Bool, handledOpenURLPath: String?) { + handledOpenURLFilePathForMouseRelease = nil + let consumed = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) + defer { handledOpenURLFilePathForMouseRelease = nil } + return (consumed, handledOpenURLFilePathForMouseRelease) } private func resolveWordUnderCursorPath(at point: NSPoint? = nil) -> WordPathResolution? { @@ -6830,7 +6832,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private func handleCommandClickRelease( at point: NSPoint, modifierFlags: NSEvent.ModifierFlags, - ghosttyConsumed: Bool + ghosttyConsumed: Bool, + handledOpenURLPath: String? ) -> WordPathResolution? { guard let surface else { return nil } let suppressCommandPathHover = shouldSuppressCommandPathHover(for: modifierFlags) @@ -6889,26 +6892,21 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif return nil } - guard !ghosttyConsumed || resolution.source == .snapshot else { + guard TerminalCommandClickFallbackPolicy().shouldOpenFallback( + ghosttyConsumed: ghosttyConsumed, + isSnapshotResolution: resolution.source == .snapshot, + resolvedPath: resolution.path, + handledOpenURLPath: handledOpenURLPath + ) else { #if DEBUG - var payload: [String: Any] = [ - "flags": debugModifierString(modifierFlags), - "ghostty_consumed": ghosttyConsumed, - "point_x": point.x, - "point_y": point.y, - "resolved_point_x": resolvedPoint?.x ?? -1, - "resolved_point_y": resolvedPoint?.y ?? -1, - "suppress_path_hover": suppressCommandPathHover - ] - for (key, value) in runtimeDebugResolutionPayload(resolution) { - payload[key] = value - } runtimeDebugLog( hypothesisID: "h3", name: "command_click_release", expected: "ghostty-consumed clicks should only skip fallback for real ghostty targets", - actual: "consumed_quicklook_resolution_skipped", - data: payload + actual: handledOpenURLPath == resolution.path + ? "open_url_already_handled_same_path" + : "consumed_quicklook_resolution_skipped", + data: runtimeDebugResolutionPayload(resolution) ) #endif return nil @@ -7073,16 +7071,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { window?.makeFirstResponder(self) ghostty_surface_mouse_pos(surface, clampedPoint.x, bounds.height - clampedPoint.y, mods) let pressHandled = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) - let releaseConsumed = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) + let release = dispatchLeftMouseRelease(surface: surface, mods: mods) let resolution = handleCommandClickRelease( at: clampedPoint, modifierFlags: flags, - ghosttyConsumed: releaseConsumed + ghosttyConsumed: release.consumed, + handledOpenURLPath: release.handledOpenURLPath ) var payload: [String: Any] = [ "pressHandled": pressHandled ? "1" : "0", - "releaseConsumed": releaseConsumed ? "1" : "0", + "releaseConsumed": release.consumed ? "1" : "0", ] if let resolution { payload["openedPath"] = resolution.path @@ -7117,12 +7116,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ghostty_surface_mouse_pos(surface, clampedPoint.x, bounds.height - clampedPoint.y, noMods) flagsChanged(with: cmdDown) let pressHandled = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, commandMods) - let releaseConsumed = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, commandMods) + let release = dispatchLeftMouseRelease(surface: surface, mods: commandMods) flagsChanged(with: cmdUp) return [ "pressHandled": pressHandled ? "1" : "0", - "releaseConsumed": releaseConsumed ? "1" : "0", + "releaseConsumed": release.consumed ? "1" : "0", ] } From 184dd17cc318cc0c108a2b8bf83401a011deb258 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 14 Jul 2026 18:38:19 -0700 Subject: [PATCH 6/6] fix: address command-click review findings --- .../TerminalCommandClickFallbackPolicy.swift | 19 ++- .../PathResolution/TerminalPathResolver.swift | 73 +++++++---- ...minalCommandClickFallbackPolicyTests.swift | 44 +++++-- .../TerminalPathResolverTests.swift | 16 +++ .../PreferredEditorLaunchCommand.swift | 20 ++-- .../PreferredEditorServiceTests.swift | 20 ++++ Sources/GhosttyTerminalView.swift | 51 ++++---- cmux.xcodeproj/project.pbxproj | 4 + cmuxTests/TerminalAndGhosttyTests.swift | 110 ----------------- ...TerminalOpenURLTargetResolutionTests.swift | 113 ++++++++++++++++++ 10 files changed, 293 insertions(+), 177 deletions(-) create mode 100644 cmuxTests/TerminalOpenURLTargetResolutionTests.swift diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalCommandClickFallbackPolicy.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalCommandClickFallbackPolicy.swift index 2ce836e96c6d..58fb273b6428 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalCommandClickFallbackPolicy.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalCommandClickFallbackPolicy.swift @@ -2,20 +2,29 @@ /// /// Ghostty may consume a mouse release without dispatching a usable link, so /// snapshot resolution remains a fallback for consumed releases. When the -/// synchronous open-URL callback already owned the same resolved file, the +/// synchronous open-URL callback already owned the same source reference, the /// fallback is suppressed to prevent opening it twice. public nonisolated struct TerminalCommandClickFallbackPolicy: Sendable { /// Creates the command-click fallback policy. public init() {} - /// Returns whether the resolved visible-line path should be opened. + /// Returns whether the resolved visible-line reference should be opened. + /// + /// - Parameters: + /// - ghosttyConsumed: Whether Ghostty consumed the mouse release. + /// - isSnapshotResolution: Whether the fallback is anchored to the clicked + /// visible-line snapshot rather than Ghostty's cached QuickLook target. + /// - resolvedReference: The source reference resolved under the pointer. + /// - handledOpenURLReference: The source reference already handled by the + /// synchronous open-URL callback for this mouse release, if any. + /// - Returns: `true` when cmux should open `resolvedReference` as a fallback. public func shouldOpenFallback( ghosttyConsumed: Bool, isSnapshotResolution: Bool, - resolvedPath: String, - handledOpenURLPath: String? + resolvedReference: TerminalPathResolution, + handledOpenURLReference: TerminalPathResolution? ) -> Bool { - guard handledOpenURLPath != resolvedPath else { return false } + guard handledOpenURLReference != resolvedReference else { return false } return !ghosttyConsumed || isSnapshotResolution } } diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift index c2fa87cd20b0..301d435679f9 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift @@ -36,13 +36,14 @@ public nonisolated struct TerminalPathResolver: Sendable { /// /// Literal spellings are probed before treating a numeric suffix as source /// location metadata. Relative spellings are resolved against the surface - /// working directory, then each fallback root. Only an existing path is - /// returned. + /// working directory, then the fallback roots. A fallback is returned only + /// when every existing fallback candidate identifies the same reference. /// /// - Parameters: /// - rawText: The raw terminal token or selected text. /// - context: The surface working directory and ordered fallback roots. - /// - Returns: The first existing path reference, or `nil`. + /// - Returns: The cwd match or one unambiguous fallback reference; otherwise + /// `nil`. public func resolvePath( _ rawText: String, context: TerminalPathResolutionContext @@ -56,24 +57,35 @@ public nonisolated struct TerminalPathResolver: Sendable { let references = spellings.filter { !shouldBypassPathResolutionForURL($0) }.flatMap { $0.terminalPathReferenceCandidates() } - let directories = resolutionDirectories(context: context) var seenPaths: Set = [] - for directory in directories { - for reference in references { - let expandedPath = (reference.path as NSString).expandingTildeInPath - guard !expandedPath.hasPrefix("/") else { continue } - let candidatePath = (directory as NSString).appendingPathComponent(expandedPath) - if let resolution = existingResolution( - path: candidatePath, - line: reference.line, - column: reference.column, - seenPaths: &seenPaths - ) { - return resolution - } + let workingDirectories = resolutionDirectories( + [context.workingDirectory].compactMap { $0 } + ) + if let workingDirectory = workingDirectories.first, + let resolution = firstRelativeResolution( + references: references, + directory: workingDirectory, + seenPaths: &seenPaths + ) { + return resolution + } + + let fallbackDirectories = resolutionDirectories(context.fallbackDirectories) + .filter { !workingDirectories.contains($0) } + var fallbackResolution: TerminalPathResolution? + for directory in fallbackDirectories { + guard let resolution = firstRelativeResolution( + references: references, + directory: directory, + seenPaths: &seenPaths + ) else { continue } + if let fallbackResolution, fallbackResolution != resolution { + return nil } + fallbackResolution = resolution } + if let fallbackResolution { return fallbackResolution } for reference in references { let expandedPath = (reference.path as NSString).expandingTildeInPath @@ -268,11 +280,9 @@ public nonisolated struct TerminalPathResolver: Sendable { Self.explicitURLSchemes.contains(parsedScheme) || rawText.contains("://") } - private func resolutionDirectories( - context: TerminalPathResolutionContext - ) -> [String] { + private func resolutionDirectories(_ rawDirectories: [String]) -> [String] { var directories: [String] = [] - for rawDirectory in [context.workingDirectory].compactMap({ $0 }) + context.fallbackDirectories { + for rawDirectory in rawDirectories { let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { continue } let expanded = (trimmed as NSString).expandingTildeInPath @@ -284,6 +294,27 @@ public nonisolated struct TerminalPathResolver: Sendable { return directories } + private func firstRelativeResolution( + references: [(path: String, line: Int?, column: Int?)], + directory: String, + seenPaths: inout Set + ) -> TerminalPathResolution? { + for reference in references { + let expandedPath = (reference.path as NSString).expandingTildeInPath + guard !expandedPath.hasPrefix("/") else { continue } + let candidatePath = (directory as NSString).appendingPathComponent(expandedPath) + if let resolution = existingResolution( + path: candidatePath, + line: reference.line, + column: reference.column, + seenPaths: &seenPaths + ) { + return resolution + } + } + return nil + } + private func existingResolution( path: String, line: Int?, diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalCommandClickFallbackPolicyTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalCommandClickFallbackPolicyTests.swift index 3a6365265e61..5ecdd780c569 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalCommandClickFallbackPolicyTests.swift +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalCommandClickFallbackPolicyTests.swift @@ -9,8 +9,8 @@ import CmuxTerminalCore !policy.shouldOpenFallback( ghosttyConsumed: true, isSnapshotResolution: true, - resolvedPath: "/repo/Sources/App.swift", - handledOpenURLPath: "/repo/Sources/App.swift" + resolvedReference: reference(line: 12, column: 4), + handledOpenURLReference: reference(line: 12, column: 4) ) ) } @@ -20,8 +20,28 @@ import CmuxTerminalCore policy.shouldOpenFallback( ghosttyConsumed: true, isSnapshotResolution: true, - resolvedPath: "/repo/Sources/Wrapped.swift", - handledOpenURLPath: "/repo/Sources/Other.swift" + resolvedReference: reference(path: "/repo/Sources/Wrapped.swift"), + handledOpenURLReference: reference(path: "/repo/Sources/Other.swift") + ) + ) + } + + @Test(arguments: [ + (TerminalPathResolution(path: "/repo/Sources/App.swift", line: 37, column: 4), + TerminalPathResolution(path: "/repo/Sources/App.swift", line: 12, column: 4)), + (TerminalPathResolution(path: "/repo/Sources/App.swift", line: 12, column: 9), + TerminalPathResolution(path: "/repo/Sources/App.swift", line: 12, column: 4)), + ]) + func preservesSnapshotFallbackForDifferentSourceLocation( + resolvedReference: TerminalPathResolution, + handledReference: TerminalPathResolution + ) { + #expect( + policy.shouldOpenFallback( + ghosttyConsumed: true, + isSnapshotResolution: true, + resolvedReference: resolvedReference, + handledOpenURLReference: handledReference ) ) } @@ -31,8 +51,8 @@ import CmuxTerminalCore !policy.shouldOpenFallback( ghosttyConsumed: true, isSnapshotResolution: false, - resolvedPath: "/repo/Sources/App.swift", - handledOpenURLPath: nil + resolvedReference: reference(), + handledOpenURLReference: nil ) ) } @@ -42,9 +62,17 @@ import CmuxTerminalCore policy.shouldOpenFallback( ghosttyConsumed: false, isSnapshotResolution: false, - resolvedPath: "/repo/Sources/App.swift", - handledOpenURLPath: nil + resolvedReference: reference(), + handledOpenURLReference: nil ) ) } + + private func reference( + path: String = "/repo/Sources/App.swift", + line: Int? = nil, + column: Int? = nil + ) -> TerminalPathResolution { + TerminalPathResolution(path: path, line: line, column: column) + } } diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift index 254bac34409d..952e9860c42f 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathResolverTests.swift @@ -248,6 +248,22 @@ private func existsIn(_ existingPaths: Set) -> @Sendable (String) -> Boo #expect(resolution?.line == 4) } + @Test func distinctFallbackMatchesFailClosed() { + let repositoryFile = "/Users/dev/repository/Sources/App.swift" + let workspaceFile = "/Users/dev/workspace/Sources/App.swift" + let resolution = TerminalPathResolver( + fileExists: existsIn([repositoryFile, workspaceFile]) + ).resolveOpenURLFileReference( + "Sources/App.swift:4", + context: TerminalPathResolutionContext( + workingDirectory: "/Users/dev/workspace/build", + fallbackDirectories: ["/Users/dev/repository", "/Users/dev/workspace"] + ) + ) + + #expect(resolution == nil) + } + @Test func literalFileEndingInNumericSuffixWins() { let literalFile = "/Users/dev/project/report.txt:12" let strippedFile = "/Users/dev/project/report.txt" diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorLaunchCommand.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorLaunchCommand.swift index a28eb47c9598..d5917c31b27e 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorLaunchCommand.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorLaunchCommand.swift @@ -2,9 +2,9 @@ import Foundation /// A preferred-editor command with shell-aware executable recognition. /// -/// Source locations are emitted only for editor CLIs whose argument contract -/// is known. Unknown commands receive the plain file path so cmux never asks -/// an editor to open a nonexistent colon-suffixed filename. +/// Source locations are emitted only for simple editor CLI commands whose +/// argument contract is known. Unknown or compound shell commands receive the +/// plain file path so cmux never guesses where location arguments would bind. nonisolated struct PreferredEditorLaunchCommand: Equatable, Sendable { let command: String @@ -43,13 +43,13 @@ nonisolated struct PreferredEditorLaunchCommand: Equatable, Sendable { } private var hasGotoFlag: Bool { - shellWords.contains { token in + shellWords?.contains { token in token == "-g" || token == "--goto" - } + } ?? false } private var executableName: String? { - let words = shellWords + guard let words = shellWords, !words.contains("--") else { return nil } var index = 0 while index < words.count, isEnvironmentAssignment(words[index]) { index += 1 @@ -76,7 +76,7 @@ nonisolated struct PreferredEditorLaunchCommand: Equatable, Sendable { return (words[index] as NSString).lastPathComponent.lowercased() } - private var shellWords: [String] { + private var shellWords: [String]? { var words: [String] = [] var current = "" var quote: Character? @@ -97,11 +97,15 @@ nonisolated struct PreferredEditorLaunchCommand: Equatable, Sendable { } else if let activeQuote = quote { if character == activeQuote { quote = nil + } else if activeQuote == "\"", character == "$" || character == "`" { + return nil } else { current.append(character) } } else if character == "'" || character == "\"" { quote = character + } else if character.isNewline || "$`#;|&<>()".contains(character) { + return nil } else if character.isWhitespace { finishWord() } else { @@ -109,7 +113,7 @@ nonisolated struct PreferredEditorLaunchCommand: Equatable, Sendable { } } - if escaping { current.append("\\") } + guard quote == nil, !escaping else { return nil } finishWord() return words } diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorServiceTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorServiceTests.swift index 13d2d4dff70c..2d79744b57f6 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorServiceTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorServiceTests.swift @@ -149,6 +149,26 @@ struct PreferredEditorServiceTests { #expect(PreferredEditorLaunchCommand(command: command).supportsSourceLocation) } + @Test(arguments: [ + "code -w && osascript -e 'display dialog 1'", + "code -w | tee /tmp/editor.log", + "code -w; osascript -e 'display dialog 1'", + "code -w > /tmp/editor.log", + "code $(printf '%s' -w)", + "code --", + ]) + func compoundCommandsDoNotClaimSourceLocationSupport(command: String) { + let launchCommand = PreferredEditorLaunchCommand(command: command) + #expect(!launchCommand.supportsSourceLocation) + #expect( + !launchCommand.shellCommand( + url: URL(fileURLWithPath: "/tmp/App.swift"), + line: 42, + column: 7 + ).contains("App.swift:42:7") + ) + } + @Test func colonLocationEditorsReceiveLocationWithoutGotoFlag() { let command = PreferredEditorLaunchCommand(command: "zed") diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index eccd7d614145..03e0662023af 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3003,7 +3003,7 @@ class GhosttyApp { preferConfiguredEditor: false ) } - surfaceView.noteHandledOpenURLFile(path: resolution.path) + surfaceView.noteHandledOpenURLFile(resolution) return (true, resolution) } resolvedFileReference = filePathResolution.resolution @@ -3029,7 +3029,7 @@ class GhosttyApp { envKey: "CMUX_UI_TEST_CAPTURE_OPEN_URL_PATH", line: target.url.absoluteString ) { - if let resolvedFileReference { performOnMain { surfaceView.noteHandledOpenURLFile(path: resolvedFileReference.path) } } + if let resolvedFileReference { performOnMain { surfaceView.noteHandledOpenURLFile(resolvedFileReference) } } return true } #endif @@ -3055,7 +3055,7 @@ class GhosttyApp { ) { NSWorkspace.shared.open(fileURL) } - surfaceView.noteHandledOpenURLFile(path: resolution.path) + surfaceView.noteHandledOpenURLFile(resolution) return true } if routed { @@ -3068,7 +3068,7 @@ class GhosttyApp { resolvedFileReference, preferConfiguredEditor: false ) - if handled { surfaceView.noteHandledOpenURLFile(path: resolvedFileReference.path) } + if handled { surfaceView.noteHandledOpenURLFile(resolvedFileReference) } return handled } } @@ -3078,7 +3078,7 @@ class GhosttyApp { cmuxDebugLog("link.openURL cmuxBrowser=disabled, opening externally url=\(target.url)") #endif return performOnMain { - surfaceView.openURLExternally(target.url, resolvedFilePath: resolvedFileReference?.path) + surfaceView.openURLExternally(target.url, resolvedFileReference: resolvedFileReference) } } switch target { @@ -3087,7 +3087,7 @@ class GhosttyApp { cmuxDebugLog("link.openURL target=external, opening externally url=\(url)") #endif return performOnMain { - surfaceView.openURLExternally(url, resolvedFilePath: resolvedFileReference?.path) + surfaceView.openURLExternally(url, resolvedFileReference: resolvedFileReference) } case let .embeddedBrowser(url): if BrowserLinkOpenSettings.shouldOpenExternally(url) { @@ -3501,7 +3501,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var keyTables: [String] = [] fileprivate private(set) var keyboardCopyModeActive = false private var wordPathHoverActive = false - private var handledOpenURLFilePathForMouseRelease: String? + private var handledOpenURLFileReferenceForMouseRelease: TerminalPathResolution? private var keyboardCopyModeConsumedKeyUps: Set = [] private var imeConsumedKeyUps: Set = [] private var keyboardCopyModeInputState = TerminalKeyboardCopyModeInputState() @@ -6508,35 +6508,36 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard let surface else { return false } let point = convert(event.locationInWindow, from: nil) let release = dispatchLeftMouseRelease(surface: surface, mods: mouseModsFromEvent(event)) - _ = handleCommandClickRelease(at: point, modifierFlags: event.modifierFlags, ghosttyConsumed: release.consumed, handledOpenURLPath: release.handledOpenURLPath) + _ = handleCommandClickRelease(at: point, modifierFlags: event.modifierFlags, ghosttyConsumed: release.consumed, handledOpenURLReference: release.handledOpenURLReference) return true } - fileprivate func noteHandledOpenURLFile(path: String) { handledOpenURLFilePathForMouseRelease = path } - fileprivate func openURLExternally(_ url: URL, resolvedFilePath: String?) -> Bool { + fileprivate func noteHandledOpenURLFile(_ reference: TerminalPathResolution) { handledOpenURLFileReferenceForMouseRelease = reference } + fileprivate func openURLExternally(_ url: URL, resolvedFileReference: TerminalPathResolution?) -> Bool { let handled = NSWorkspace.shared.open(url) - if handled, let path = resolvedFilePath ?? (url.isFileURL ? url.path : nil) { noteHandledOpenURLFile(path: path) } + if handled, let reference = resolvedFileReference ?? (url.isFileURL ? TerminalPathResolution(path: url.path, line: nil, column: nil) : nil) { noteHandledOpenURLFile(reference) } return handled } private func dispatchLeftMouseRelease( surface: ghostty_surface_t, mods: ghostty_input_mods_e - ) -> (consumed: Bool, handledOpenURLPath: String?) { - handledOpenURLFilePathForMouseRelease = nil + ) -> (consumed: Bool, handledOpenURLReference: TerminalPathResolution?) { + handledOpenURLFileReferenceForMouseRelease = nil let consumed = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) - defer { handledOpenURLFilePathForMouseRelease = nil } - return (consumed, handledOpenURLFilePathForMouseRelease) + defer { handledOpenURLFileReferenceForMouseRelease = nil } + return (consumed, handledOpenURLFileReferenceForMouseRelease) } - private func resolveWordUnderCursorPath(at point: NSPoint? = nil) -> WordPathResolution? { + private func resolveWordUnderCursorPath(at point: NSPoint? = nil, includeFallbackDirectories: Bool = true) -> WordPathResolution? { guard let surface = surface else { return nil } guard let termSurface = terminalSurface, let workspace = termSurface.owningWorkspace(), !workspace.isRemoteTerminalSurface(termSurface.id) else { return nil } - let context = resolvedWordPathContext(workspace: workspace, terminalSurface: termSurface) + let fullContext = resolvedWordPathContext(workspace: workspace, terminalSurface: termSurface) + let context = includeFallbackDirectories ? fullContext : TerminalPathResolutionContext(workingDirectory: fullContext.workingDirectory) guard context.workingDirectory != nil || !context.fallbackDirectories.isEmpty else { return nil } @@ -6667,7 +6668,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return } - let resolution = resolveWordUnderCursorPath(at: point) + let resolution = resolveWordUnderCursorPath(at: point, includeFallbackDirectories: false) if resolution != nil { if !wordPathHoverActive { wordPathHoverActive = true @@ -6833,7 +6834,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { at point: NSPoint, modifierFlags: NSEvent.ModifierFlags, ghosttyConsumed: Bool, - handledOpenURLPath: String? + handledOpenURLReference: TerminalPathResolution? ) -> WordPathResolution? { guard let surface else { return nil } let suppressCommandPathHover = shouldSuppressCommandPathHover(for: modifierFlags) @@ -6895,16 +6896,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard TerminalCommandClickFallbackPolicy().shouldOpenFallback( ghosttyConsumed: ghosttyConsumed, isSnapshotResolution: resolution.source == .snapshot, - resolvedPath: resolution.path, - handledOpenURLPath: handledOpenURLPath + resolvedReference: resolution.resolution, + handledOpenURLReference: handledOpenURLReference ) else { #if DEBUG runtimeDebugLog( hypothesisID: "h3", name: "command_click_release", expected: "ghostty-consumed clicks should only skip fallback for real ghostty targets", - actual: handledOpenURLPath == resolution.path - ? "open_url_already_handled_same_path" + actual: handledOpenURLReference == resolution.resolution + ? "open_url_already_handled_same_reference" : "consumed_quicklook_resolution_skipped", data: runtimeDebugResolutionPayload(resolution) ) @@ -7040,7 +7041,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) ) - let resolution = suppressCommandPathHover ? nil : resolveWordUnderCursorPath(at: clampedPoint) + let resolution = suppressCommandPathHover ? nil : resolveWordUnderCursorPath(at: clampedPoint, includeFallbackDirectories: false) updateWordPathHover( at: clampedPoint, cmdHeld: true, @@ -7076,7 +7077,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { at: clampedPoint, modifierFlags: flags, ghosttyConsumed: release.consumed, - handledOpenURLPath: release.handledOpenURLPath + handledOpenURLReference: release.handledOpenURLReference ) var payload: [String: Any] = [ diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 519f23711d91..30d8d2bbb3c3 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1637,6 +1637,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 596100000000000000000007 /* TerminalNotificationStore+NativeNotificationDeliveryTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 596100000000000000000006 /* TerminalNotificationStore+NativeNotificationDeliveryTesting.swift */; }; A5F10000000000000000000D /* TerminalNotificationStore+WorkspaceNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F10000000000000000000E /* TerminalNotificationStore+WorkspaceNotifications.swift */; }; A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; }; + C80970010000000000000006 /* TerminalOpenURLTargetResolutionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C80970010000000000000005 /* TerminalOpenURLTargetResolutionTests.swift */; }; 0A110711000000000000001C /* TerminalOutputTeeCallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A110711000000000000001D /* TerminalOutputTeeCallback.swift */; }; 0A110711000000000000001E /* TerminalOutputTeeContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A110711000000000000001F /* TerminalOutputTeeContext.swift */; }; D0B10000A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */; }; @@ -3505,6 +3506,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 596100000000000000000006 /* TerminalNotificationStore+NativeNotificationDeliveryTesting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotificationStore+NativeNotificationDeliveryTesting.swift"; sourceTree = ""; }; A5F10000000000000000000E /* TerminalNotificationStore+WorkspaceNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotificationStore+WorkspaceNotifications.swift"; sourceTree = ""; }; A5001092 /* TerminalNotificationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationStore.swift; sourceTree = ""; }; + C80970010000000000000005 /* TerminalOpenURLTargetResolutionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalOpenURLTargetResolutionTests.swift; sourceTree = ""; }; 0A110711000000000000001D /* TerminalOutputTeeCallback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalOutputTeeCallback.swift; sourceTree = ""; }; 0A110711000000000000001F /* TerminalOutputTeeContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalOutputTeeContext.swift; sourceTree = ""; }; D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPaneDropTargetView.swift; sourceTree = ""; }; @@ -5429,6 +5431,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D3052001000000000000002 /* TerminalSurfaceResizePolicyTests.swift */, EC010C02 /* TerminalUploadCommandTests.swift */, 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */, + C80970010000000000000005 /* TerminalOpenURLTargetResolutionTests.swift */, 7863B0020000000000000001 /* GhosttyBackgroundAndPanelAppearanceTests.swift */, A11E5E1E0000000000000012 /* TerminalSelectionAccessibilityIngressGateTests.swift */, 570ED364A7E41085FB2D31FE /* MainWindowSelfSizingTests.swift */, @@ -7928,6 +7931,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5A5A503A1B2C3D4E5F60718 /* TerminalNotificationQueueTests.swift in Sources */, A5E380700000000000000001 /* TerminalNotificationSocketActionTests.swift in Sources */, 596100000000000000000007 /* TerminalNotificationStore+NativeNotificationDeliveryTesting.swift in Sources */, + C80970010000000000000006 /* TerminalOpenURLTargetResolutionTests.swift in Sources */, C7261F020000000000000001 /* TerminalPointerFocusActivationPolicyTests.swift in Sources */, D5671101D5671101D5671101 /* TerminalScrollSpeedSettingsFileStoreTests.swift in Sources */, C0DE53360000000000000001 /* TerminalSearchOverlayMouseReleaseTests.swift in Sources */, diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 2d320b467d83..c78da44f1e58 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -5500,116 +5500,6 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { } -final class TerminalOpenURLTargetResolutionTests: XCTestCase { - func testResolvesHTTPSAsEmbeddedBrowser() throws { - let target = try XCTUnwrap(TerminalBrowserHostNormalizer().resolveOpenURLTarget("https://example.com/path?q=1")) - switch target { - case let .embeddedBrowser(url): - XCTAssertEqual(url.scheme, "https") - XCTAssertEqual(url.host, "example.com") - XCTAssertEqual(url.path, "/path") - default: - XCTFail("Expected web URL to route to embedded browser") - } - } - - func testResolvesBareDomainAsEmbeddedBrowser() throws { - let target = try XCTUnwrap(TerminalBrowserHostNormalizer().resolveOpenURLTarget("example.com/docs")) - switch target { - case let .embeddedBrowser(url): - XCTAssertEqual(url.scheme, "https") - XCTAssertEqual(url.host, "example.com") - XCTAssertEqual(url.path, "/docs") - default: - XCTFail("Expected bare domain to be normalized as an HTTPS browser URL") - } - } - - func testResolvesSingleLabelHostAsEmbeddedBrowser() throws { - let target = try XCTUnwrap(TerminalBrowserHostNormalizer().resolveOpenURLTarget("go/docs")) - switch target { - case let .embeddedBrowser(url): - XCTAssertEqual(url.host, "go") - XCTAssertEqual(url.path, "/docs") - default: - XCTFail("Expected a single-label internal host to remain browser-routable") - } - } - - @MainActor - func testLocationCapableEditorBypassesPreviewButPlainOpenerDoesNot() throws { - let suiteName = "cmux-tests-\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - defaults.set(true, forKey: "openSupportedFilesInCmux") - - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-location-routing-\(UUID().uuidString).swift") - try "let value = 1\n".write(to: fileURL, atomically: true, encoding: .utf8) - defer { try? FileManager.default.removeItem(at: fileURL) } - let resolution = TerminalPathResolution(path: fileURL.path, line: 1, column: 5) - let routeSettings = FileRouteSettingsStore(defaults: defaults) - - let locationRouter = CommandClickFileOpenRouter( - routeSettings: routeSettings, - supportsExternalLocations: { true }, - openExternal: { _, _, _ in } - ) - XCTAssertFalse(locationRouter.shouldRouteInCmux(resolution: resolution)) - - let plainRouter = CommandClickFileOpenRouter( - routeSettings: routeSettings, - supportsExternalLocations: { false }, - openExternal: { _, _, _ in } - ) - XCTAssertTrue(plainRouter.shouldRouteInCmux(resolution: resolution)) - } - - func testResolvesFileSchemeAsExternal() throws { - let target = try XCTUnwrap(TerminalBrowserHostNormalizer().resolveOpenURLTarget("file:///tmp/cmux.txt")) - switch target { - case let .external(url): - XCTAssertTrue(url.isFileURL) - XCTAssertEqual(url.path, "/tmp/cmux.txt") - default: - XCTFail("Expected file URL to open externally") - } - } - - func testResolvesAbsolutePathAsExternalFileURL() throws { - let target = try XCTUnwrap(TerminalBrowserHostNormalizer().resolveOpenURLTarget("/tmp/cmux-path.txt")) - switch target { - case let .external(url): - XCTAssertTrue(url.isFileURL) - XCTAssertEqual(url.path, "/tmp/cmux-path.txt") - default: - XCTFail("Expected absolute file path to open externally") - } - } - - func testResolvesNonWebSchemeAsExternal() throws { - let target = try XCTUnwrap(TerminalBrowserHostNormalizer().resolveOpenURLTarget("mailto:test@example.com")) - switch target { - case let .external(url): - XCTAssertEqual(url.scheme, "mailto") - default: - XCTFail("Expected non-web scheme to open externally") - } - } - - func testResolvesHostlessHTTPSAsExternal() throws { - let target = try XCTUnwrap(TerminalBrowserHostNormalizer().resolveOpenURLTarget("https:///tmp/cmux.txt")) - switch target { - case let .external(url): - XCTAssertEqual(url.scheme, "https") - XCTAssertNil(url.host) - XCTAssertEqual(url.path, "/tmp/cmux.txt") - default: - XCTFail("Expected hostless HTTPS URL to open externally") - } - } -} - final class TerminalControllerSocketListenerHealthTests: XCTestCase { private let transport = SocketTransport() diff --git a/cmuxTests/TerminalOpenURLTargetResolutionTests.swift b/cmuxTests/TerminalOpenURLTargetResolutionTests.swift new file mode 100644 index 000000000000..79e754d61ddf --- /dev/null +++ b/cmuxTests/TerminalOpenURLTargetResolutionTests.swift @@ -0,0 +1,113 @@ +import Foundation +import Testing +import CmuxSettings +import CmuxTerminalCore + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite struct TerminalOpenURLTargetResolutionTests { + @Test func resolvesHTTPSAsEmbeddedBrowser() throws { + let target = try #require(TerminalBrowserHostNormalizer().resolveOpenURLTarget("https://example.com/path?q=1")) + guard case let .embeddedBrowser(url) = target else { + Issue.record("Expected web URL to route to embedded browser") + return + } + #expect(url.scheme == "https") + #expect(url.host == "example.com") + #expect(url.path == "/path") + } + + @Test func resolvesBareDomainAsEmbeddedBrowser() throws { + let target = try #require(TerminalBrowserHostNormalizer().resolveOpenURLTarget("example.com/docs")) + guard case let .embeddedBrowser(url) = target else { + Issue.record("Expected bare domain to be normalized as an HTTPS browser URL") + return + } + #expect(url.scheme == "https") + #expect(url.host == "example.com") + #expect(url.path == "/docs") + } + + @Test func resolvesSingleLabelHostAsEmbeddedBrowser() throws { + let target = try #require(TerminalBrowserHostNormalizer().resolveOpenURLTarget("go/docs")) + guard case let .embeddedBrowser(url) = target else { + Issue.record("Expected a single-label internal host to remain browser-routable") + return + } + #expect(url.host == "go") + #expect(url.path == "/docs") + } + + @MainActor + @Test func locationCapableEditorBypassesPreviewButPlainOpenerDoesNot() throws { + let suiteName = "cmux-tests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set(true, forKey: "openSupportedFilesInCmux") + + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-location-routing-\(UUID().uuidString).swift") + try "let value = 1\n".write(to: fileURL, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: fileURL) } + let resolution = TerminalPathResolution(path: fileURL.path, line: 1, column: 5) + let routeSettings = FileRouteSettingsStore(defaults: defaults) + + let locationRouter = CommandClickFileOpenRouter( + routeSettings: routeSettings, + supportsExternalLocations: { true }, + openExternal: { _, _, _ in } + ) + #expect(!locationRouter.shouldRouteInCmux(resolution: resolution)) + + let plainRouter = CommandClickFileOpenRouter( + routeSettings: routeSettings, + supportsExternalLocations: { false }, + openExternal: { _, _, _ in } + ) + #expect(plainRouter.shouldRouteInCmux(resolution: resolution)) + } + + @Test func resolvesFileSchemeAsExternal() throws { + let target = try #require(TerminalBrowserHostNormalizer().resolveOpenURLTarget("file:///tmp/cmux.txt")) + guard case let .external(url) = target else { + Issue.record("Expected file URL to open externally") + return + } + #expect(url.isFileURL) + #expect(url.path == "/tmp/cmux.txt") + } + + @Test func resolvesAbsolutePathAsExternalFileURL() throws { + let target = try #require(TerminalBrowserHostNormalizer().resolveOpenURLTarget("/tmp/cmux-path.txt")) + guard case let .external(url) = target else { + Issue.record("Expected absolute file path to open externally") + return + } + #expect(url.isFileURL) + #expect(url.path == "/tmp/cmux-path.txt") + } + + @Test func resolvesNonWebSchemeAsExternal() throws { + let target = try #require(TerminalBrowserHostNormalizer().resolveOpenURLTarget("mailto:test@example.com")) + guard case let .external(url) = target else { + Issue.record("Expected non-web scheme to open externally") + return + } + #expect(url.scheme == "mailto") + } + + @Test func resolvesHostlessHTTPSAsExternal() throws { + let target = try #require(TerminalBrowserHostNormalizer().resolveOpenURLTarget("https:///tmp/cmux.txt")) + guard case let .external(url) = target else { + Issue.record("Expected hostless HTTPS URL to open externally") + return + } + #expect(url.scheme == "https") + #expect(url.host == nil) + #expect(url.path == "/tmp/cmux.txt") + } +}