From f6c949add702e82311565ee9b90a384eb5c8bcf3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 30 Mar 2026 04:49:17 -0700 Subject: [PATCH] Add cmd-click fallback for bare filenames (ls output) (#2294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add cmd-click fallback for bare filenames in terminal output When cmd-clicking text that ghostty's built-in URL/path regex doesn't match (e.g. bare filenames from `ls` like README.md, src, config.json), fall back to checking if the word under cursor is a valid file or directory in the terminal panel's CWD. Uses the existing ghostty_surface_quicklook_word API to extract the word, then resolves it against the panel's working directory and opens it if it exists. * Add pointing-hand cursor on Cmd-hover over bare filenames When holding Cmd and hovering over a word that resolves to an existing file/directory in the terminal's CWD, show the pointing-hand cursor. Hooks into mouseMoved and flagsChanged so the cursor updates both when moving the mouse with Cmd held and when pressing/releasing Cmd while the mouse is stationary. * Address PR review comments - Refresh ghostty mouse position before quicklook_word in mouseUp and flagsChanged so stale coordinates don't resolve the wrong word - Use failable String(bytes:encoding:.utf8) instead of lossy decoding - Skip absolute-path words (already handled by ghostty's regex) - Guard against remote terminal sessions (local fileExists would be wrong) - Use invalidateCursorRects instead of forcing iBeam on hover deactivation to avoid overwriting ghostty/AppKit's cursor state * Add preferred editor setting for cmd-click file opens New "Open Files With" picker in Settings > App lets users choose which editor opens when cmd-clicking bare filenames. Options: System Default, Cursor, VS Code, Windsurf, Zed, Sublime Text, Xcode. Reuses the existing TerminalDirectoryOpenTarget app detection infrastructure. Defaults to system default (NSWorkspace default handler). * Replace editor picker with free-form command field, respect $VISUAL/$EDITOR The "Open Files With" setting is now a text field where users can type any command (code, zed, subl, open -a Xcode, etc.). Resolution order: 1. User-configured command from settings 2. $VISUAL environment variable 3. $EDITOR environment variable 4. System default (NSWorkspace) Removes the fixed PreferredEditor enum in favor of flexibility. * Fix stuck pointing-hand cursor using NSCursor push/pop invalidateCursorRects did nothing since the view has no cursor rects. Use NSCursor push/pop stack instead so the previous cursor is properly restored when the hover deactivates. * Remove $VISUAL/$EDITOR fallback, use system default when empty $EDITOR/$VISUAL are typically terminal editors (vim, nano) that can't launch as GUI subprocesses. Empty field now falls back to system default (opens in Finder/default app) which is the expected behavior. * Address PR review comments (round 2) - Use broader CWD fallback chain (panelDirectories → requestedWorkingDirectory → workspace currentDirectory) matching Workspace split creation logic - Pop cursor stack in viewDidMoveToWindow to balance push if view is removed while hover is active - Reset preferredEditorCommand in resetAllSettings() - Fall back to NSWorkspace.open when the custom editor command exits non-zero (e.g. command not found exits 127 but /bin/sh itself succeeds) * Clear cursor on mouse exit to prevent stuck pointing-hand --------- Co-authored-by: Lawrence Chen --- Sources/GhosttyTerminalView.swift | 100 +++++++++++++++++++++++++++++- Sources/cmuxApp.swift | 60 ++++++++++++++++++ 2 files changed, 159 insertions(+), 1 deletion(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index cfa3b4a8..0c3068b0 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4473,6 +4473,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var keySequence: [ghostty_input_trigger_s] = [] private var keyTables: [String] = [] fileprivate private(set) var keyboardCopyModeActive = false + private var wordPathHoverActive = false private var keyboardCopyModeConsumedKeyUps: Set = [] private var keyboardCopyModeInputState = TerminalKeyboardCopyModeInputState() private var keyboardCopyModeViewportRow: Int? @@ -4722,6 +4723,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { NotificationCenter.default.removeObserver(windowObserver) self.windowObserver = nil } + // Balance the cursor stack if the view is removed while hover is active + if wordPathHoverActive { + wordPathHoverActive = false + NSCursor.pop() + } #if DEBUG dlog( "surface.view.windowMove surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + @@ -6185,6 +6191,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { keyEvent.text = nil keyEvent.composing = false _ = ghostty_surface_key(surface, keyEvent) + // Refresh ghostty's mouse position so quicklook_word uses current coordinates + // when Cmd is pressed while the pointer is stationary. + let point = convert(event.locationInWindow, from: nil) + ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) + updateWordPathHover(cmdHeld: event.modifierFlags.contains(.command)) } private func modsFromEvent(_ event: NSEvent) -> ghostty_input_mods_e { @@ -6424,7 +6435,89 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { dlog("terminal.mouseUp surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))]") #endif guard let surface = surface else { return } - _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, modsFromEvent(event)) + let consumed = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, modsFromEvent(event)) + + // Fallback: if Cmd was held and ghostty didn't handle the click as a link, + // check if the word under cursor is a valid file/directory in the terminal's CWD. + // This enables cmd-click on bare filenames from commands like `ls`. + if !consumed && event.modifierFlags.contains(.command) { + // Refresh ghostty's cached mouse position so quicklook_word reads + // up-to-date coordinates (mouseDown skips pos update on double-click). + let point = convert(event.locationInWindow, from: nil) + ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) + tryOpenWordAsPath() + } + } + + /// 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() { + guard let resolvedPath = resolveWordUnderCursorAsPath() else { return } + + #if DEBUG + dlog("link.wordFallback resolved=\(resolvedPath)") + #endif + + PreferredEditorSettings.open(URL(fileURLWithPath: resolvedPath)) + } + + /// 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() -> String? { + guard let surface = surface else { return nil } + + var text = ghostty_text_s() + guard ghostty_surface_quicklook_word(surface, &text) else { return nil } + defer { ghostty_surface_free_text(surface, &text) } + + guard text.text_len > 0, let ptr = text.text else { return nil } + let wordData = Data(bytes: ptr, count: Int(text.text_len)) + guard let decodedWord = String(bytes: wordData, encoding: .utf8) else { return nil } + let word = decodedWord.trimmingCharacters(in: .whitespacesAndNewlines) + guard !word.isEmpty, !word.hasPrefix("/") else { return nil } + + guard let termSurface = terminalSurface, + let workspace = termSurface.owningWorkspace(), + !workspace.isRemoteTerminalSurface(termSurface.id) else { return nil } + + // Use the same CWD fallback chain as Workspace split creation: + // panelDirectories (live OSC 7) → requestedWorkingDirectory → workspace currentDirectory + let cwd: String? = { + if let dir = workspace.panelDirectories[termSurface.id]?.trimmingCharacters(in: .whitespacesAndNewlines), + !dir.isEmpty { return dir } + if let dir = workspace.terminalPanel(for: termSurface.id)? + .requestedWorkingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), + !dir.isEmpty { return dir } + let dir = workspace.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + return dir.isEmpty ? nil : dir + }() + guard let cwd else { return nil } + + let resolvedPath = (cwd as NSString).appendingPathComponent(word) + guard FileManager.default.fileExists(atPath: resolvedPath) else { return nil } + return resolvedPath + } + + /// Update the pointing-hand cursor when Cmd-hovering over a bare filename + /// that exists in the terminal's CWD. + private func updateWordPathHover(cmdHeld: Bool) { + guard cmdHeld else { + if wordPathHoverActive { + wordPathHoverActive = false + NSCursor.pop() + } + return + } + + if resolveWordUnderCursorAsPath() != nil { + if !wordPathHoverActive { + wordPathHoverActive = true + NSCursor.pointingHand.push() + } + } else if wordPathHoverActive { + wordPathHoverActive = false + NSCursor.pop() + } } override func rightMouseDown(with event: NSEvent) { @@ -6590,6 +6683,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard let surface = surface else { return } let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) + updateWordPathHover(cmdHeld: event.modifierFlags.contains(.command)) } override func mouseEntered(with event: NSEvent) { @@ -6618,6 +6712,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } override func mouseExited(with event: NSEvent) { + if wordPathHoverActive { + wordPathHoverActive = false + NSCursor.pop() + } guard let surface = surface else { return } if NSEvent.pressedMouseButtons != 0 { return diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 7a24a3ed..8ba0d97d 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -3879,6 +3879,50 @@ enum TelemetrySettings { static let enabledForCurrentLaunch = isEnabled() } +enum PreferredEditorSettings { + static let key = "preferredEditorCommand" + + /// Returns the configured editor command, or nil to use system default. + static func resolvedCommand(defaults: UserDefaults = .standard) -> String? { + guard let stored = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines), + !stored.isEmpty else { + return nil + } + return stored + } + + /// Open a file path with the user's preferred editor, falling back to system default. + static func open(_ url: URL) { + guard let command = resolvedCommand() else { + NSWorkspace.shared.open(url) + return + } + let path = url.path + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", "\(command) \(shellQuote(path))"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + // Check exit status on a background thread; fall back on failure + // (e.g. command not found exits 127 but /bin/sh itself succeeds) + DispatchQueue.global(qos: .userInitiated).async { + process.waitUntilExit() + if process.terminationStatus != 0 { + DispatchQueue.main.async { NSWorkspace.shared.open(url) } + } + } + } catch { + NSWorkspace.shared.open(url) + } + } + + private static func shellQuote(_ s: String) -> String { + "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} + struct SettingsView: View { private let contentTopInset: CGFloat = 8 private let pickerColumnWidth: CGFloat = 196 @@ -3894,6 +3938,7 @@ struct SettingsView: View { private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled @AppStorage(TelemetrySettings.sendAnonymousTelemetryKey) private var sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry + @AppStorage(PreferredEditorSettings.key) private var preferredEditorCommand = "" @AppStorage("cmuxPortBase") private var cmuxPortBase = 9100 @AppStorage("cmuxPortRange") private var cmuxPortRange = 10 @AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue @@ -4591,6 +4636,20 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + String(localized: "settings.app.preferredEditor", defaultValue: "Open Files With"), + subtitle: String(localized: "settings.app.preferredEditor.subtitle", defaultValue: "Command to open files on Cmd-click. Leave empty for system default.") + ) { + TextField( + String(localized: "settings.app.preferredEditor.placeholder", defaultValue: "e.g. code, zed, subl"), + text: $preferredEditorCommand + ) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + } + + SettingsCardDivider() + SettingsCardRow( String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"), subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.") @@ -5835,6 +5894,7 @@ struct SettingsView: View { socketControlMode = SocketControlSettings.defaultMode.rawValue claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry + preferredEditorCommand = "" browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled browserThemeMode = BrowserThemeSettings.defaultMode.rawValue