From def55639f7c6749ad2ef407b248e39a5def51a0e Mon Sep 17 00:00:00 2001 From: Eridanus <45489268+Eridanus117@users.noreply.github.com> Date: Thu, 2 Jul 2026 05:52:22 +0800 Subject: [PATCH 1/9] Add failing tests for terminal path:line click-to-open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminal links of the form `path:line[:column]` (the convention Claude Code and Codex print for click-to-open) do not open at the referenced line today: absolute refs silently fail, relative refs are misrouted to the browser omnibar. Introduce the resolution/opening primitives as stubs and cover the target behavior with tests so this commit is red and the fix (next commit) turns it green: - `String.splitTerminalPathLineSuffix()` splits a trailing `:line[:column]`. - `TerminalPathResolver.resolveOpenURLFileReference(_:cwd:)` resolves a token to an existing file plus optional line/column, probing the literal path first and the line-stripped path second. - `TerminalFileReference.fileURL` carries line/column as a `#L…` fragment. - `PreferredEditorService.open` should honor that fragment and hand a `path:line:column` argument (plus `-g`) to VS Code-family editors. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../String+TerminalPathTokens.swift | 15 ++ .../PathResolution/TerminalPathResolver.swift | 42 +++++ .../TerminalPathLineReferenceTests.swift | 153 ++++++++++++++++++ .../PreferredEditorLineReferenceTests.swift | 82 ++++++++++ 4 files changed, 292 insertions(+) create mode 100644 Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathLineReferenceTests.swift create mode 100644 Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.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..7c859e6f72b0 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/String+TerminalPathTokens.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/String+TerminalPathTokens.swift @@ -114,6 +114,21 @@ 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?)? { + return nil + } + /// Candidate path spellings derived from the receiver: the raw text, its /// shell-unescaped and shell-unquoted variants, each with and without /// trailing terminal punctuation. Order is probe order; duplicates are diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift index 3b6476d5ac45..0aa0c878255a 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/TerminalPathResolver.swift @@ -105,4 +105,46 @@ 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; other schemes still fall through to the + /// file-existence probe, which gates false positives. + /// + /// - 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? { + return nil + } +} + +/// 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 { + return URL(fileURLWithPath: path) + } } diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathLineReferenceTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathLineReferenceTests.swift new file mode 100644 index 000000000000..dd66ccd71b23 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathLineReferenceTests.swift @@ -0,0 +1,153 @@ +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) + } +} + +@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 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/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift new file mode 100644 index 000000000000..a99e7baee8a2 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift @@ -0,0 +1,82 @@ +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")) + } +} From 8abaaa9bddda7c7e743626b4bb96d619b321c530 Mon Sep 17 00:00:00 2001 From: Eridanus <45489268+Eridanus117@users.noreply.github.com> Date: Thu, 2 Jul 2026 05:57:49 +0800 Subject: [PATCH 2/9] Open terminal path:line links in the preferred editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve terminal file links of the form `path:line[:column]` — the convention Claude Code and Codex print for click-to-open — to the referenced file and open it at that line in the configured editor: - `String.splitTerminalPathLineSuffix()` peels a trailing `:line[:column]`. - `TerminalPathResolver.resolveOpenURLFileReference(_:cwd:)` resolves a token to an existing file plus optional line/column, probing the literal path first (so a file that really ends in a colon-number wins) and the line-stripped path second; web URLs are never treated as file paths. - `TerminalFileReference.fileURL` carries line/column across the package boundary as a `#L[:]` fragment. - `PreferredEditorService.open` decodes that fragment and hands `path:line:column` (plus `-g`) to VS Code-family editors, falling back to the fragment-free file URL for other editors and the system opener. - The terminal open-URL handler routes any line reference to the editor, which also fixes relative `dir/file.swift:line` links that previously fell through to the browser omnibar. Verified with package-level swift test on CmuxTerminalCore and CmuxWorkspaces (red without the fix, green with it). No new user-facing strings. Supersedes #3977 by @austinywang, which implemented the same user-facing behavior before #5894 extracted the resolver into CmuxTerminalCore / CmuxWorkspaces; the `:line` split and editor `-g` dispatch are adapted from that PR onto the new package seams. Co-authored-by: austinpower1258 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../String+TerminalPathTokens.swift | 26 +++- .../PathResolution/TerminalPathResolver.swift | 76 +++++++++++- .../TerminalPathLineReferenceTests.swift | 26 ++++ .../FileOpen/PreferredEditorService.swift | 115 +++++++++++++++++- .../PreferredEditorLineReferenceTests.swift | 64 ++++++++++ Sources/GhosttyTerminalView.swift | 27 ++-- 6 files changed, 318 insertions(+), 16 deletions(-) diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/String+TerminalPathTokens.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/String+TerminalPathTokens.swift index 7c859e6f72b0..c931c71854aa 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/String+TerminalPathTokens.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/String+TerminalPathTokens.swift @@ -126,7 +126,31 @@ extension String { /// /// - Returns: The base path and its line/optional column, or `nil`. func splitTerminalPathLineSuffix() -> (path: String, line: Int, column: Int?)? { - return nil + guard let lastColon = lastIndex(of: ":") else { return nil } + let lastSuffix = self[index(after: lastColon)...] + guard !lastSuffix.isEmpty, + lastSuffix.allSatisfy(\.isNumber), + let lastNumber = Int(lastSuffix), + lastNumber > 0 else { + return nil + } + + let beforeLastColon = self[.. 0 { + let path = String(beforeLastColon[.. 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? { + 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 TerminalFileReference(path: standardizedPath, line: line, column: column) + } } /// An existing file plus an optional editor `line[:column]` target, carried @@ -145,6 +211,10 @@ public struct TerminalFileReference: Equatable, Sendable { /// A `file://` URL for ``path``, carrying the line/column as a `#L…` /// fragment when a line is present. public var fileURL: URL { - return URL(fileURLWithPath: path) + 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 index dd66ccd71b23..2f55ffebb550 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathLineReferenceTests.swift +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathLineReferenceTests.swift @@ -109,6 +109,32 @@ private func existsIn(_ existingPaths: Set) -> @Sendable (String) -> Boo #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( diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift index 43e4219cca35..ce890029dc23 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,110 @@ 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`. + 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) + guard let commandName = tokens.first.map({ ($0 as NSString).lastPathComponent.lowercased() }), + gotoEditorCommandNames.contains(commandName) 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 + } + if raw.first == "L" || raw.first == "l" { + 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 + } + if parts.count == 2, let column = Int(parts[1]), column > 0 { + return (line, column) + } + return (line, nil) + } + + /// Splits a configured editor command into shell words, honoring single and + /// double quotes so a quoted binary path (e.g. an app bundle with spaces) + /// stays one token. Escapes are not interpreted; command paths needing them + /// simply fall back to the non-goto argument. + private static func commandTokens(_ command: String) -> [String] { + var tokens: [String] = [] + var current = "" + var quote: Character? + var hasToken = false + + for character in command { + 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 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 index a99e7baee8a2..e630f3f955a2 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift @@ -80,3 +80,67 @@ struct PreferredEditorLineReferenceTests { #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 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") + } +} 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 From 9cec0db8018143e374dc76397f782adeaa2c05a1 Mon Sep 17 00:00:00 2001 From: Eridanus <45489268+Eridanus117@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:55:32 +0800 Subject: [PATCH 3/9] Address review feedback: ASCII digits, backslash-escaped editor paths, dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `splitTerminalPathLineSuffix` now requires ASCII digits, so a Unicode numeral suffix (`²`, full-width `42`, Devanagari) is not read as a line number (previously relied on `Int()` rejecting it downstream). - `commandTokens` interprets backslash escapes, so an editor command with a backslash-escaped binary path (`/Applications/Visual\ Studio\ Code.app/…`) is still recognized as a goto editor and gets `-g` + `path:line:column`. - Extract the shared `~`-expand / cwd-join / standardize / dedup step into `standardizedExistingPath`, used by both `resolveQuicklookPath` and the new file-reference resolver (removes the duplicated candidate pipeline). Not changed: goto detection still keys off the command's first shell word, so a wrapper prefix (`env VAR=1 code`, `arch -arm64 code`) opens the file without `-g` — same first-word model as the configured-editor command elsewhere; wrapper-prefixed editor commands are uncommon and degrade to a plain open. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../String+TerminalPathTokens.swift | 12 +++++-- .../PathResolution/TerminalPathResolver.swift | 33 ++++++++++--------- .../TerminalPathLineReferenceTests.swift | 5 +++ .../FileOpen/PreferredEditorService.swift | 20 +++++++++-- .../PreferredEditorLineReferenceTests.swift | 9 +++++ 5 files changed, 59 insertions(+), 20 deletions(-) diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/String+TerminalPathTokens.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/String+TerminalPathTokens.swift index c931c71854aa..8255a1198823 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/String+TerminalPathTokens.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/PathResolution/String+TerminalPathTokens.swift @@ -129,7 +129,7 @@ extension String { guard let lastColon = lastIndex(of: ":") else { return nil } let lastSuffix = self[index(after: lastColon)...] guard !lastSuffix.isEmpty, - lastSuffix.allSatisfy(\.isNumber), + lastSuffix.allSatisfy(\.isASCIIDigit), let lastNumber = Int(lastSuffix), lastNumber > 0 else { return nil @@ -139,7 +139,7 @@ extension String { if let lineColon = beforeLastColon.lastIndex(of: ":") { let lineSuffix = beforeLastColon[beforeLastColon.index(after: lineColon)...] if !lineSuffix.isEmpty, - lineSuffix.allSatisfy(\.isNumber), + lineSuffix.allSatisfy(\.isASCIIDigit), let lineNumber = Int(lineSuffix), lineNumber > 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 8b91052561e2..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 } } @@ -175,6 +163,21 @@ public struct TerminalPathResolver: Sendable { 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("/") { @@ -187,7 +190,7 @@ public struct TerminalPathResolver: Sendable { let standardizedPath = (candidatePath as NSString).standardizingPath guard seenPaths.insert(standardizedPath).inserted else { return nil } guard fileExists(standardizedPath) else { return nil } - return TerminalFileReference(path: standardizedPath, line: line, column: column) + return standardizedPath } } diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathLineReferenceTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathLineReferenceTests.swift index 2f55ffebb550..79b6dd5c1211 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathLineReferenceTests.swift +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPathLineReferenceTests.swift @@ -42,6 +42,11 @@ private func existsIn(_ existingPaths: Set) -> @Sendable (String) -> Boo @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 { diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift index ce890029dc23..0b6baf782f01 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift @@ -156,16 +156,27 @@ extension PreferredEditorService { } /// Splits a configured editor command into shell words, honoring single and - /// double quotes so a quoted binary path (e.g. an app bundle with spaces) - /// stays one token. Escapes are not interpreted; command paths needing them - /// simply fall back to the non-goto argument. + /// double quotes and backslash escapes so a binary path with spaces — a + /// quoted app bundle or a backslash-escaped path — stays one token. 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 != "'" { + escaping = true + hasToken = true + continue + } if let active = quote { if character == active { quote = nil @@ -191,6 +202,9 @@ extension PreferredEditorService { current.append(character) hasToken = true } + if escaping { + current.append("\\") + } if hasToken { tokens.append(current) } diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift index e630f3f955a2..1daf4531e03a 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift @@ -126,6 +126,15 @@ struct PreferredEditorInvocationTests { #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 leavesUnknownEditorWithBarePath() { let invocation = PreferredEditorService.editorInvocation( forURL: fragmentURL(path: "/tmp/main.swift", fragment: "L42:5"), From 5dcaa445ff7e8026219f26c8049a19372258ba01 Mon Sep 17 00:00:00 2001 From: Eridanus <45489268+Eridanus117@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:31:30 +0800 Subject: [PATCH 4/9] Add failing test: backslash inside double quotes misread as --goto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `commandTokens` interprets a backslash as escaping the next character even inside double quotes, so `code "\--goto"` tokenizes to `--goto` and the invocation drops the `-g` flag. But `/bin/sh -c` (which actually runs the command) preserves a literal backslash inside double quotes, passing `\--goto`. This test pins the correct behavior — `-g` must still be added — and is red against the current tokenizer. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PreferredEditorLineReferenceTests.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift index 1daf4531e03a..63be9b9fe377 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift @@ -135,6 +135,19 @@ struct PreferredEditorInvocationTests { #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 leavesUnknownEditorWithBarePath() { let invocation = PreferredEditorService.editorInvocation( forURL: fragmentURL(path: "/tmp/main.swift", fragment: "L42:5"), From 46e57247006193148bab62325b9e1fb8d7559633 Mon Sep 17 00:00:00 2001 From: Eridanus <45489268+Eridanus117@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:32:19 +0800 Subject: [PATCH 5/9] Interpret backslash escapes only outside quotes in commandTokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match `/bin/sh -c` word-splitting: a backslash is literal inside single or double quotes and only escapes the next character when unquoted. Previously the escape fired inside double quotes too, so `code "\--goto"` tokenized to `--goto` and the invocation wrongly saw an existing goto flag, dropping the `-g` that a `path:line` link needs. Unquoted backslash-escaped binary paths (e.g. `/Applications/Visual\ Studio\ Code.app/…`) are unaffected — the escape still applies there. Turns the prior commit's test green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../FileOpen/PreferredEditorService.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift index 0b6baf782f01..e9f219cf92c5 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift @@ -155,9 +155,12 @@ extension PreferredEditorService { return (line, nil) } - /// Splits a configured editor command into shell words, honoring single and - /// double quotes and backslash escapes so a binary path with spaces — a - /// quoted app bundle or a backslash-escaped path — stays one token. + /// 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 = "" @@ -172,7 +175,7 @@ extension PreferredEditorService { hasToken = true continue } - if character == "\\", quote != "'" { + if character == "\\", quote == nil { escaping = true hasToken = true continue From 332ad5a1d49ac4baae13666091a311f071b56875 Mon Sep 17 00:00:00 2001 From: Eridanus <45489268+Eridanus117@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:34:56 +0800 Subject: [PATCH 6/9] Add failing test: wrapper-prefixed goto editor drops -g MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `editorInvocation` keys the goto-editor check off only the command's first shell word, so a realistic wrapper prefix (`arch -arm64 code` on Apple Silicon, `env VAR=1 code`, `nohup code`) is not recognized as a VS Code-family editor and the file opens without `-g` — no line jump. This test pins the expected `-g` and is red against the first-word check. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../FileOpen/PreferredEditorLineReferenceTests.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift index 63be9b9fe377..22d9988d2f7f 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift @@ -148,6 +148,18 @@ struct PreferredEditorInvocationTests { #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"), From f17c2cc2d65baf0c955e9e249dc8efb86f8cedc0 Mon Sep 17 00:00:00 2001 From: Eridanus <45489268+Eridanus117@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:35:31 +0800 Subject: [PATCH 7/9] Scan all command words for the goto editor, not just the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recognize a VS Code-family editor anywhere in the configured command so a wrapper prefix (`arch -arm64 code`, `env VAR=1 code`, `nohup code`) still gets `-g` + `path:line:column`. The first-word-only check missed these and degraded to a plain open with no line jump. Turns the prior commit's test green. The only cost is that a non-goto editor command whose bare argument is literally named `code`/`cursor`/… would be treated as goto-capable, but an editor command is ` [flags]` (flags start with `-`), so a bare same-named argument is not a real configuration. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../FileOpen/PreferredEditorService.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift index e9f219cf92c5..ed4af9806fbb 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift @@ -114,6 +114,10 @@ extension PreferredEditorService { /// 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 @@ -124,8 +128,10 @@ extension PreferredEditorService { } let tokens = commandTokens(command) - guard let commandName = tokens.first.map({ ($0 as NSString).lastPathComponent.lowercased() }), - gotoEditorCommandNames.contains(commandName) else { + let isGotoEditor = tokens.contains { token in + gotoEditorCommandNames.contains((token as NSString).lastPathComponent.lowercased()) + } + guard isGotoEditor else { return (gotoFlag: "", argument: path) } From 970e98f8cb9fc011508369af7977e9a0bbd49b86 Mon Sep 17 00:00:00 2001 From: Eridanus <45489268+Eridanus117@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:02:56 +0800 Subject: [PATCH 8/9] Add failing tests: fragment parser guesses on malformed input `lineColumn(fromFragment:)` accepts fragments outside the `L[:]` contract: a bare `#42` (no `L` marker) becomes line 42, and a colon group with a missing/invalid/nonpositive column (`L42:`, `L42:abc`, `L42:0`) silently falls back to line-only. These tests pin fail-closed behavior and are red against the current permissive parser. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PreferredEditorLineReferenceTests.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift index 22d9988d2f7f..ac58ca7a7def 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/FileOpen/PreferredEditorLineReferenceTests.swift @@ -177,4 +177,30 @@ struct PreferredEditorInvocationTests { #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") + } + } } From c05260e00d087ad826d616987aa54a614d9c6d19 Mon Sep 17 00:00:00 2001 From: Eridanus <45489268+Eridanus117@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:03:27 +0800 Subject: [PATCH 9/9] Fail closed on malformed line-reference fragments Parse only the exact `L[:]` fragment contract that `TerminalFileReference.fileURL` emits: require the `L` marker (so a bare `#42` anchor is not read as a line) and require a positive-integer column when a colon group is present (so `L42:`, `L42:abc`, `L42:0` are rejected rather than silently opening at line-only). Matches the repo's fail-closed convention for open/dispatch routing. Turns the prior commit's tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../FileOpen/PreferredEditorService.swift | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift index ed4af9806fbb..5a6ce145960b 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/FileOpen/PreferredEditorService.swift @@ -146,16 +146,21 @@ extension PreferredEditorService { guard var raw = fragment?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } - if raw.first == "L" || raw.first == "l" { - raw.removeFirst() - } + // 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 } - if parts.count == 2, let column = Int(parts[1]), column > 0 { + // 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)