diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/String+TerminalPathTokens.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/String+TerminalPathTokens.swift index d331985565e8..8255a1198823 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/String+TerminalPathTokens.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/String+TerminalPathTokens.swift @@ -114,6 +114,45 @@ extension String { return String(output) } + /// Splits a trailing editor line reference (`:line` or `:line:column`) off + /// the receiver, returning the base path plus the parsed positive integers. + /// + /// This is the `path:line[:column]` convention that agents (Claude Code, + /// Codex) print for click-to-open. The suffix parses only when the segment + /// after the last colon is a positive integer; a leading second colon group + /// that is also a positive integer becomes the column. Returns `nil` when + /// there is no such trailing group, so a literal path is left untouched and + /// resolved as-is by the caller. + /// + /// - Returns: The base path and its line/optional column, or `nil`. + func splitTerminalPathLineSuffix() -> (path: String, line: Int, column: Int?)? { + guard let lastColon = lastIndex(of: ":") else { return nil } + let lastSuffix = self[index(after: lastColon)...] + guard !lastSuffix.isEmpty, + lastSuffix.allSatisfy(\.isASCIIDigit), + let lastNumber = Int(lastSuffix), + lastNumber > 0 else { + return nil + } + + let beforeLastColon = self[.. 0 { + let path = String(beforeLastColon[.. { } } +extension Character { + /// Whether the character is an ASCII digit `0`–`9`, excluding the Unicode + /// numerals (`²`, `½`, Devanagari digits, …) that `isNumber` also accepts. + fileprivate var isASCIIDigit: Bool { + isASCII && isNumber + } +} + extension [Character] { /// Whether the character at `index` hard-delimits a path token: tabs and /// newlines always, spaces only when doubled (cell-grid padding). diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift index 3b6476d5ac45..09aee850fbf9 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift @@ -46,20 +46,8 @@ public struct TerminalPathResolver: Sendable { 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) - } - - let standardizedPath = (candidatePath as NSString).standardizingPath - guard seenPaths.insert(standardizedPath).inserted else { continue } - if fileExists(standardizedPath) { - return standardizedPath + if let path = standardizedExistingPath(for: normalizedToken, cwd: cwd, seenPaths: &seenPaths) { + return path } } @@ -105,4 +93,131 @@ public struct TerminalPathResolver: Sendable { guard URL(string: trimmed)?.scheme == nil else { return nil } return resolveQuicklookPath(trimmed, cwd: cwd) } + + /// Resolves an open-URL request payload to an existing file plus an optional + /// `line[:column]` reference. + /// + /// Like ``resolveOpenURLFilePath(_:cwd:)`` but understands the + /// `path:line[:column]` convention: each candidate spelling is probed + /// as-is first (so a literal path that really contains a colon wins) and + /// then with a trailing line reference stripped. `http`/`https` text is + /// never treated as a file path, and a line-stripped candidate is only + /// probed when it is shaped like a path (has a separator or extension), so + /// a bare `host:port` that merely matches a same-named directory in `cwd` + /// is not mistaken for a file reference. + /// + /// - Parameters: + /// - rawText: The raw open-URL text from the runtime. + /// - cwd: The surface's working directory used for relative candidates. + /// - Returns: The first existing file reference, or `nil`. + public func resolveOpenURLFileReference(_ rawText: String, cwd: String?) -> TerminalFileReference? { + let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + // Web URLs are never file paths. Every other spelling — scheme-less + // text, or a bogus scheme Foundation parses out of `name:line` — stays + // eligible and is gated by the file-existence probe below. + let scheme = URL(string: trimmed)?.scheme?.lowercased() + guard scheme != "http", scheme != "https" else { return nil } + + var seenPaths: Set = [] + for token in trimmed.pathResolutionCandidates() { + let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedToken.isEmpty else { continue } + + // Probe the literal token first so a path that really ends in a + // colon-number wins over the line-reference reading, then probe the + // line-stripped path. + if let reference = existingFileReference( + token: normalizedToken, line: nil, column: nil, cwd: cwd, seenPaths: &seenPaths + ) { + return reference + } + if let split = normalizedToken.splitTerminalPathLineSuffix(), + looksLikeFilePath(split.path), + let reference = existingFileReference( + token: split.path, line: split.line, column: split.column, cwd: cwd, seenPaths: &seenPaths + ) { + return reference + } + } + + return nil + } + + /// Whether a line-stripped token is shaped like a file path — it carries a + /// separator or a file extension — rather than a bare `host:port` / + /// `word:number` token that only happens to name an existing directory in + /// the working directory. Bare extension-less names (e.g. `Makefile:12`) + /// are intentionally excluded here; those arrive through the cmd-click + /// word-under-cursor path, not open-URL. + private func looksLikeFilePath(_ token: String) -> Bool { + token.contains("/") || !(token as NSString).pathExtension.isEmpty + } + + /// Standardizes `token` against `cwd`, dedupes against `seenPaths`, and + /// returns a reference carrying `line`/`column` when the file exists. + private func existingFileReference( + token: String, + line: Int?, + column: Int?, + cwd: String?, + seenPaths: inout Set + ) -> TerminalFileReference? { + guard let path = standardizedExistingPath(for: token, cwd: cwd, seenPaths: &seenPaths) else { + return nil + } + return TerminalFileReference(path: path, line: line, column: column) + } + + /// Expands `~`, resolves `token` against `cwd` when relative, standardizes, + /// and dedupes against `seenPaths`, returning the standardized path when the + /// file exists and has not already been probed. Shared by the QuickLook and + /// file-reference resolvers. + private func standardizedExistingPath( + for token: String, + cwd: String?, + seenPaths: inout Set + ) -> String? { + let expandedToken = (token as NSString).expandingTildeInPath + let candidatePath: String + if expandedToken.hasPrefix("/") { + candidatePath = expandedToken + } else { + guard let cwd, !cwd.isEmpty else { return nil } + candidatePath = (cwd as NSString).appendingPathComponent(expandedToken) + } + + let standardizedPath = (candidatePath as NSString).standardizingPath + guard seenPaths.insert(standardizedPath).inserted else { return nil } + guard fileExists(standardizedPath) else { return nil } + return standardizedPath + } +} + +/// An existing file plus an optional editor `line[:column]` target, carried +/// from terminal link resolution to the file opener. +/// +/// The line/column travel to the editor as a URL fragment (`#L` or +/// `#L:`) so a plain `URL` is enough to cross the package +/// boundary; ``fileURL`` is the single place that encoding lives. +public struct TerminalFileReference: Equatable, Sendable { + public let path: String + public let line: Int? + public let column: Int? + + public init(path: String, line: Int?, column: Int?) { + self.path = path + self.line = line + self.column = column + } + + /// A `file://` URL for ``path``, carrying the line/column as a `#L…` + /// fragment when a line is present. + public var fileURL: URL { + let base = URL(fileURLWithPath: path) + guard let line else { return base } + var components = URLComponents(url: base, resolvingAgainstBaseURL: false) + components?.fragment = column.map { "L\(line):\($0)" } ?? "L\(line)" + return components?.url ?? base + } } diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathLineReferenceTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathLineReferenceTests.swift new file mode 100644 index 000000000000..79b6dd5c1211 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathLineReferenceTests.swift @@ -0,0 +1,184 @@ +import Foundation +import Testing +@testable import CmuxTerminalCore + +private func existsIn(_ existingPaths: Set) -> @Sendable (String) -> Bool { + { path in existingPaths.contains((path as NSString).standardizingPath) } +} + +@Suite struct TerminalPathLineSuffixSplitTests { + @Test func splitsTrailingLineNumber() throws { + let split = try #require("/Users/dev/app/main.swift:42".splitTerminalPathLineSuffix()) + #expect(split.path == "/Users/dev/app/main.swift") + #expect(split.line == 42) + #expect(split.column == nil) + } + + @Test func splitsTrailingLineAndColumn() throws { + let split = try #require("/Users/dev/app/main.swift:42:5".splitTerminalPathLineSuffix()) + #expect(split.path == "/Users/dev/app/main.swift") + #expect(split.line == 42) + #expect(split.column == 5) + } + + @Test func splitsRelativePathReference() throws { + let split = try #require("Sources/App.swift:1".splitTerminalPathLineSuffix()) + #expect(split.path == "Sources/App.swift") + #expect(split.line == 1) + } + + @Test func returnsNilWhenNoLineSuffix() { + #expect("/Users/dev/app/main.swift".splitTerminalPathLineSuffix() == nil) + } + + @Test func returnsNilForNonNumericSuffix() { + #expect("/Users/dev/app/main.swift:main".splitTerminalPathLineSuffix() == nil) + } + + @Test func returnsNilForZeroLine() { + #expect("/Users/dev/app/main.swift:0".splitTerminalPathLineSuffix() == nil) + } + + @Test func returnsNilForEmptyPath() { + #expect(":42".splitTerminalPathLineSuffix() == nil) + } + + @Test func returnsNilForNonASCIINumeralSuffix() { + // Full-width digits are `isNumber` but not editor line numbers. + #expect("/Users/dev/app/main.swift:42".splitTerminalPathLineSuffix() == nil) + } +} + +@Suite struct TerminalOpenURLFileReferenceTests { + @Test func resolvesAbsolutePathWithLine() throws { + let existingFile = "/Users/dev/project/Sources/App.swift" + let reference = try #require( + TerminalPathResolver(fileExists: existsIn([existingFile])).resolveOpenURLFileReference( + "\(existingFile):42", + cwd: "/Users/dev/project" + ) + ) + #expect(reference.path == existingFile) + #expect(reference.line == 42) + #expect(reference.column == nil) + } + + @Test func resolvesAbsolutePathWithLineAndColumn() throws { + let existingFile = "/Users/dev/project/Sources/App.swift" + let reference = try #require( + TerminalPathResolver(fileExists: existsIn([existingFile])).resolveOpenURLFileReference( + "\(existingFile):42:5", + cwd: "/Users/dev/project" + ) + ) + #expect(reference.path == existingFile) + #expect(reference.line == 42) + #expect(reference.column == 5) + } + + @Test func resolvesRelativePathWithLineAgainstCwd() throws { + let cwd = "/Users/dev/project" + let existingFile = "/Users/dev/project/Sources/App.swift" + let reference = try #require( + TerminalPathResolver(fileExists: existsIn([existingFile])).resolveOpenURLFileReference( + "Sources/App.swift:1", + cwd: cwd + ) + ) + #expect(reference.path == existingFile) + #expect(reference.line == 1) + } + + @Test func prefersLiteralPathThatReallyContainsColonNumber() throws { + let literalPath = "/Users/dev/project/weird:42" + let reference = try #require( + TerminalPathResolver(fileExists: existsIn([literalPath])).resolveOpenURLFileReference( + literalPath, + cwd: "/Users/dev/project" + ) + ) + #expect(reference.path == literalPath) + #expect(reference.line == nil) + } + + @Test func resolvesPlainExistingPathWithoutLine() throws { + let existingFile = "/Users/dev/project/README.md" + let reference = try #require( + TerminalPathResolver(fileExists: existsIn([existingFile])).resolveOpenURLFileReference( + existingFile, + cwd: "/Users/dev/project" + ) + ) + #expect(reference.path == existingFile) + #expect(reference.line == nil) + #expect(reference.column == nil) + } + + @Test func doesNotTreatHostPortWithSameNamedDirectoryAsFileReference() { + // `config/` exists in cwd; a bare `config:8080` (host:port) must stay a + // link, not resolve to `config` opened at line 8080 in the editor. + let existingDir = "/Users/dev/project/config" + #expect( + TerminalPathResolver(fileExists: existsIn([existingDir])).resolveOpenURLFileReference( + "config:8080", + cwd: "/Users/dev/project" + ) == nil + ) + } + + @Test func resolvesExtensionlessFileWithLineWhenPathHasSeparator() throws { + // A path-shaped extension-less token (`bin/run`) still resolves; only + // bare `word:number` tokens are excluded. + let existingFile = "/Users/dev/project/bin/run" + let reference = try #require( + TerminalPathResolver(fileExists: existsIn([existingFile])).resolveOpenURLFileReference( + "bin/run:7", + cwd: "/Users/dev/project" + ) + ) + #expect(reference.path == existingFile) + #expect(reference.line == 7) + } + + @Test func returnsNilWhenBasePathDoesNotExist() { + #expect( + TerminalPathResolver(fileExists: existsIn([])).resolveOpenURLFileReference( + "Sources/Missing.swift:1", + cwd: "/Users/dev/project" + ) == nil + ) + } + + @Test func neverTreatsWebURLAsFileReference() { + #expect( + TerminalPathResolver(fileExists: { _ in true }).resolveOpenURLFileReference( + "https://example.com:443", + cwd: "/Users/dev/project" + ) == nil + ) + #expect( + TerminalPathResolver(fileExists: { _ in true }).resolveOpenURLFileReference( + "http://example.com:80", + cwd: "/Users/dev/project" + ) == nil + ) + } +} + +@Suite struct TerminalFileReferenceFileURLTests { + @Test func encodesLineAndColumnAsFragment() { + let reference = TerminalFileReference(path: "/Users/dev/app/main.swift", line: 42, column: 5) + #expect(reference.fileURL.path == "/Users/dev/app/main.swift") + #expect(reference.fileURL.fragment == "L42:5") + } + + @Test func encodesLineOnlyAsFragment() { + let reference = TerminalFileReference(path: "/Users/dev/app/main.swift", line: 42, column: nil) + #expect(reference.fileURL.fragment == "L42") + } + + @Test func omitsFragmentWhenNoLine() { + let reference = TerminalFileReference(path: "/Users/dev/app/main.swift", line: nil, column: nil) + #expect(reference.fileURL.fragment == nil) + } +} diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift index 43e4219cca35..5a6ce145960b 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift @@ -57,21 +57,30 @@ public struct PreferredEditorService: FileOpening { } public func open(_ url: URL) { + // A `#L[:]` fragment (set by terminal `path:line` links) + // travels to goto-capable editors as a `path:line:column` argument; the + // system-open fallback always uses the fragment-free file URL. + let path = url.path if capture.appendLineIfConfigured( envKey: "CMUX_UI_TEST_CAPTURE_OPEN_PATH", - line: url.path + line: path ) { return } + let fallbackURL = URL(fileURLWithPath: path) + guard let command = editor.resolvedCommand else { - systemOpener.openWithSystemDefault(url) + systemOpener.openWithSystemDefault(fallbackURL) return } + let invocation = Self.editorInvocation(forURL: url, command: command) let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/sh") - process.arguments = ["-c", "\(command) \(url.path.posixShellSingleQuoted)"] + process.arguments = [ + "-c", "\(command)\(invocation.gotoFlag) \(invocation.argument.posixShellSingleQuoted)" + ] process.standardOutput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice @@ -81,14 +90,138 @@ public struct PreferredEditorService: FileOpening { // 127 but /bin/sh itself launched fine). guard process.terminationStatus != 0 else { return } Task { @MainActor in - systemOpener.openWithSystemDefault(url) + systemOpener.openWithSystemDefault(fallbackURL) } } do { try process.run() } catch { - systemOpener.openWithSystemDefault(url) + systemOpener.openWithSystemDefault(fallbackURL) + } + } +} + +extension PreferredEditorService { + /// Editors whose CLI understands a `path:line[:column]` goto argument. + private static let gotoEditorCommandNames: Set = [ + "code", "code-insiders", "cursor", "cursor-insiders", "windsurf", + ] + + /// Builds the goto flag and file argument for `command` opening `url`. + /// + /// With no `#L` fragment, or for an editor that is not goto-capable, this + /// is the bare file path. For a goto-capable editor with a line fragment, + /// the argument becomes `path:line[:column]` and ` -g` is prepended unless + /// the configured command already carries `-g`/`--goto`. + /// + /// Every shell word is scanned for a recognized editor name, not just the + /// first, so a wrapper prefix (`arch -arm64 code`, `env VAR=1 code`) still + /// resolves the goto flag. + static func editorInvocation( + forURL url: URL, + command: String + ) -> (gotoFlag: String, argument: String) { + let path = url.path + guard let reference = lineColumn(fromFragment: url.fragment) else { + return (gotoFlag: "", argument: path) + } + + let tokens = commandTokens(command) + let isGotoEditor = tokens.contains { token in + gotoEditorCommandNames.contains((token as NSString).lastPathComponent.lowercased()) + } + guard isGotoEditor else { + return (gotoFlag: "", argument: path) + } + + let argument = reference.column.map { "\(path):\(reference.line):\($0)" } + ?? "\(path):\(reference.line)" + let hasGotoFlag = tokens.contains("-g") || tokens.contains("--goto") + return (gotoFlag: hasGotoFlag ? "" : " -g", argument: argument) + } + + /// Parses a `#L[:]` fragment into positive integers. + private static func lineColumn(fromFragment fragment: String?) -> (line: Int, column: Int?)? { + guard var raw = fragment?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + // The fragment contract is exactly `L[:]` (see + // `TerminalFileReference.fileURL`). Fail closed on anything else rather + // than guessing a location: require the `L` marker. + guard raw.first == "L" || raw.first == "l" else { return nil } + raw.removeFirst() + let parts = raw.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + guard let linePart = parts.first, + let line = Int(linePart), + line > 0 else { + return nil + } + // A colon group must carry a positive-integer column; reject a missing + // or malformed column rather than opening at a guessed line-only. + if parts.count == 2 { + guard let column = Int(parts[1]), column > 0 else { return nil } + return (line, column) + } + return (line, nil) + } + + /// Splits a configured editor command into shell words. Single and double + /// quotes keep a binary path with spaces (a quoted app bundle) as one token; + /// an unquoted backslash escapes the next character, so a backslash-escaped + /// path stays one token too. Backslashes are literal inside quotes, matching + /// how `/bin/sh -c` word-splits the command that is actually run — so a + /// quoted arg like `"\--goto"` is not misread as the `--goto` flag. + private static func commandTokens(_ command: String) -> [String] { + var tokens: [String] = [] + var current = "" + var quote: Character? + var escaping = false + var hasToken = false + + for character in command { + if escaping { + current.append(character) + escaping = false + hasToken = true + continue + } + if character == "\\", quote == nil { + escaping = true + hasToken = true + continue + } + if let active = quote { + if character == active { + quote = nil + } else { + current.append(character) + } + hasToken = true + continue + } + if character == "'" || character == "\"" { + quote = character + hasToken = true + continue + } + if character.isWhitespace { + if hasToken { + tokens.append(current) + current = "" + hasToken = false + } + continue + } + current.append(character) + hasToken = true + } + if escaping { + current.append("\\") + } + if hasToken { + tokens.append(current) } + return tokens } } diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift new file mode 100644 index 000000000000..ac58ca7a7def --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift @@ -0,0 +1,206 @@ +import Foundation +import Testing +@testable import CmuxWorkspaces +import CmuxSettings +import CmuxTestSupport + +@MainActor +private final class RecordingSystemOpener: SystemFileOpening { + private(set) var openedURLs: [URL] = [] + func openWithSystemDefault(_ url: URL) { openedURLs.append(url) } +} + +private struct FixedEditor: PreferredEditorReading { + var resolvedCommand: String? +} + +@Suite("PreferredEditorService line references") +@MainActor +struct PreferredEditorLineReferenceTests { + private func makeScratchDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-file-open-line-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func fragmentURL(for fileURL: URL, fragment: String) throws -> URL { + var components = try #require(URLComponents(url: fileURL, resolvingAgainstBaseURL: false)) + components.fragment = fragment + return try #require(components.url) + } + + /// Names the editor script `code` so it is recognized as a VS Code-family + /// goto editor, writes each received argument to a marker, and returns it. + private func makeCapturingEditor(named name: String, in scratch: URL) throws -> (command: String, marker: URL) { + let marker = scratch.appendingPathComponent("args.txt") + let script = scratch.appendingPathComponent(name) + try #""" + #!/bin/sh + : > '\#(marker.path)' + for arg in "$@"; do + printf '%s\n' "$arg" >> '\#(marker.path)' + done + """#.write(to: script, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.path) + return (script.path, marker) + } + + private func waitForMarker(_ marker: URL) async throws -> String { + for _ in 0..<200 where !FileManager.default.fileExists(atPath: marker.path) { + try await Task.sleep(for: .milliseconds(25)) + } + // The marker is created empty then appended to; give the append a beat. + for _ in 0..<200 { + let contents = (try? String(contentsOf: marker, encoding: .utf8)) ?? "" + if !contents.isEmpty { return contents } + try await Task.sleep(for: .milliseconds(25)) + } + return (try? String(contentsOf: marker, encoding: .utf8)) ?? "" + } + + @Test func gotoEditorReceivesLineAndColumnAndGotoFlag() async throws { + let scratch = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: scratch) } + let sourceFile = scratch.appendingPathComponent("main.swift") + try "let x = 1\n".write(to: sourceFile, atomically: true, encoding: .utf8) + let editor = try makeCapturingEditor(named: "code", in: scratch) + + let service = PreferredEditorService( + editor: FixedEditor(resolvedCommand: editor.command), + capture: UITestCaptureSink(environment: [:]), + systemOpener: RecordingSystemOpener() + ) + + service.open(try fragmentURL(for: sourceFile, fragment: "L42:5")) + + let received = try await waitForMarker(editor.marker) + let lines = received.split(separator: "\n").map(String.init) + #expect(lines.contains("-g")) + #expect(lines.contains("\(sourceFile.path):42:5")) + } +} + +@Suite("PreferredEditorService editor invocation") +@MainActor +struct PreferredEditorInvocationTests { + private func fragmentURL(path: String, fragment: String?) -> URL { + var components = URLComponents(url: URL(fileURLWithPath: path), resolvingAgainstBaseURL: false) + components?.fragment = fragment + return components?.url ?? URL(fileURLWithPath: path) + } + + @Test func addsGotoFlagAndLineColumnForGotoEditor() { + let invocation = PreferredEditorService.editorInvocation( + forURL: fragmentURL(path: "/tmp/main.swift", fragment: "L42:5"), + command: "code" + ) + #expect(invocation.gotoFlag == " -g") + #expect(invocation.argument == "/tmp/main.swift:42:5") + } + + @Test func addsLineOnlyWhenNoColumn() { + let invocation = PreferredEditorService.editorInvocation( + forURL: fragmentURL(path: "/tmp/main.swift", fragment: "L42"), + command: "cursor" + ) + #expect(invocation.gotoFlag == " -g") + #expect(invocation.argument == "/tmp/main.swift:42") + } + + @Test func keepsExistingGotoFlag() { + let invocation = PreferredEditorService.editorInvocation( + forURL: fragmentURL(path: "/tmp/main.swift", fragment: "L42:5"), + command: "code --goto" + ) + #expect(invocation.gotoFlag == "") + #expect(invocation.argument == "/tmp/main.swift:42:5") + } + + @Test func recognizesQuotedBinaryPath() { + let invocation = PreferredEditorService.editorInvocation( + forURL: fragmentURL(path: "/tmp/main.swift", fragment: "L42:5"), + command: "\"/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code\"" + ) + #expect(invocation.gotoFlag == " -g") + #expect(invocation.argument == "/tmp/main.swift:42:5") + } + + @Test func recognizesBackslashEscapedBinaryPath() { + let invocation = PreferredEditorService.editorInvocation( + forURL: fragmentURL(path: "/tmp/main.swift", fragment: "L42:5"), + command: "/Applications/Visual\\ Studio\\ Code.app/Contents/Resources/app/bin/code --goto" + ) + #expect(invocation.gotoFlag == "") + #expect(invocation.argument == "/tmp/main.swift:42:5") + } + + @Test func doesNotTreatBackslashInsideDoubleQuotesAsGotoFlag() { + // `/bin/sh -c` preserves a literal backslash inside double quotes, so + // `code "\--goto"` passes `\--goto` (not `--goto`) to the editor. The + // tokenizer must match that; otherwise it wrongly reads an existing + // `--goto` and drops the `-g` the line jump needs. + let invocation = PreferredEditorService.editorInvocation( + forURL: fragmentURL(path: "/tmp/main.swift", fragment: "L42:5"), + command: "code \"\\--goto\"" + ) + #expect(invocation.gotoFlag == " -g") + #expect(invocation.argument == "/tmp/main.swift:42:5") + } + + @Test func recognizesGotoEditorAfterWrapperPrefix() { + // A wrapper prefix (`arch -arm64`, `env VAR=1`, `nohup`) is a realistic + // way to launch a VS Code-family editor, so the goto editor is not + // necessarily the first shell word — every word is scanned. + let invocation = PreferredEditorService.editorInvocation( + forURL: fragmentURL(path: "/tmp/main.swift", fragment: "L42:5"), + command: "arch -arm64 code" + ) + #expect(invocation.gotoFlag == " -g") + #expect(invocation.argument == "/tmp/main.swift:42:5") + } + + @Test func leavesUnknownEditorWithBarePath() { + let invocation = PreferredEditorService.editorInvocation( + forURL: fragmentURL(path: "/tmp/main.swift", fragment: "L42:5"), + command: "mate" + ) + #expect(invocation.gotoFlag == "") + #expect(invocation.argument == "/tmp/main.swift") + } + + @Test func leavesBarePathWhenNoFragment() { + let invocation = PreferredEditorService.editorInvocation( + forURL: fragmentURL(path: "/tmp/main.swift", fragment: nil), + command: "code" + ) + #expect(invocation.gotoFlag == "") + #expect(invocation.argument == "/tmp/main.swift") + } + + @Test func rejectsFragmentMissingLMarker() { + // The fragment contract is `L[:]`; a bare `#42` (e.g. a + // document anchor, not a line reference) must not be guessed into a + // line jump. + let invocation = PreferredEditorService.editorInvocation( + forURL: fragmentURL(path: "/tmp/main.swift", fragment: "42"), + command: "code" + ) + #expect(invocation.gotoFlag == "") + #expect(invocation.argument == "/tmp/main.swift") + } + + @Test func rejectsFragmentWithMalformedColumn() { + // A colon group with a missing or non-positive-integer column is + // malformed transport; fail closed rather than opening at a guessed + // line-only location. + for fragment in ["L42:", "L42:abc", "L42:0"] { + let invocation = PreferredEditorService.editorInvocation( + forURL: fragmentURL(path: "/tmp/main.swift", fragment: fragment), + command: "code" + ) + #expect(invocation.gotoFlag == "", "\(fragment) should not resolve a goto") + #expect(invocation.argument == "/tmp/main.swift", "\(fragment) should open plain") + } + } +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 2df7cca50f8c..e46373b1880b 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3044,25 +3044,38 @@ class GhosttyApp { workspace: workspace, surfaceId: termSurface.id ) - guard let resolvedPath = TerminalPathResolver().resolveOpenURLFilePath(trimmedUrlString, cwd: cwd) else { + guard let reference = TerminalPathResolver().resolveOpenURLFileReference(trimmedUrlString, cwd: cwd) else { return (false, nil) } - guard CommandClickFileOpenRouter.shouldRouteInCmux(path: resolvedPath) else { - return (false, resolvedPath) + // A `path:line[:column]` reference always opens in the + // preferred editor: cmux's in-app file/markdown viewers + // cannot jump to a line, and jumping there is the whole + // point of the reference. This also routes relative + // `dir/file.swift:line` refs to the editor instead of the + // browser omnibar they previously fell through to. + if reference.line != nil { + #if DEBUG + cmuxDebugLog("link.openURL resolvedAsFileReference=\(reference.fileURL)") + #endif + PreferredEditorService(defaults: .standard).open(reference.fileURL) + return (true, nil) + } + guard CommandClickFileOpenRouter.shouldRouteInCmux(path: reference.path) else { + return (false, reference.path) } #if DEBUG - cmuxDebugLog("link.openURL resolvedAsFilePath=\(resolvedPath)") + cmuxDebugLog("link.openURL resolvedAsFilePath=\(reference.path)") #endif - let fileURL = URL(fileURLWithPath: resolvedPath) + let fileURL = URL(fileURLWithPath: reference.path) CommandClickFileOpenRouter.deferredOpenFileInCmux( workspace: workspace, preferredWorkspaceId: workspace.id, surfaceId: termSurface.id, - filePath: resolvedPath + filePath: reference.path ) { NSWorkspace.shared.open(fileURL) } - return (true, resolvedPath) + return (true, nil) } if let fallbackPath = filePathResolution.fallbackPath { normalizedOpenURLString = fallbackPath