Add cmd-click fallback for bare filenames (ls output) (#2294)

* 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 <lawrencecchen@users.noreply.github.com>
This commit is contained in:
Lawrence Chen 2026-03-30 04:49:17 -07:00 committed by GitHub
parent d6d9130c72
commit f6c949add7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 159 additions and 1 deletions

View file

@ -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<UInt16> = []
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

View file

@ -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