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:
parent
d6d9130c72
commit
f6c949add7
2 changed files with 159 additions and 1 deletions
|
|
@ -4473,6 +4473,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||||
private var keySequence: [ghostty_input_trigger_s] = []
|
private var keySequence: [ghostty_input_trigger_s] = []
|
||||||
private var keyTables: [String] = []
|
private var keyTables: [String] = []
|
||||||
fileprivate private(set) var keyboardCopyModeActive = false
|
fileprivate private(set) var keyboardCopyModeActive = false
|
||||||
|
private var wordPathHoverActive = false
|
||||||
private var keyboardCopyModeConsumedKeyUps: Set<UInt16> = []
|
private var keyboardCopyModeConsumedKeyUps: Set<UInt16> = []
|
||||||
private var keyboardCopyModeInputState = TerminalKeyboardCopyModeInputState()
|
private var keyboardCopyModeInputState = TerminalKeyboardCopyModeInputState()
|
||||||
private var keyboardCopyModeViewportRow: Int?
|
private var keyboardCopyModeViewportRow: Int?
|
||||||
|
|
@ -4722,6 +4723,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||||
NotificationCenter.default.removeObserver(windowObserver)
|
NotificationCenter.default.removeObserver(windowObserver)
|
||||||
self.windowObserver = nil
|
self.windowObserver = nil
|
||||||
}
|
}
|
||||||
|
// Balance the cursor stack if the view is removed while hover is active
|
||||||
|
if wordPathHoverActive {
|
||||||
|
wordPathHoverActive = false
|
||||||
|
NSCursor.pop()
|
||||||
|
}
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
dlog(
|
dlog(
|
||||||
"surface.view.windowMove surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
"surface.view.windowMove surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||||
|
|
@ -6185,6 +6191,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||||
keyEvent.text = nil
|
keyEvent.text = nil
|
||||||
keyEvent.composing = false
|
keyEvent.composing = false
|
||||||
_ = ghostty_surface_key(surface, keyEvent)
|
_ = 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 {
|
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))]")
|
dlog("terminal.mouseUp surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))]")
|
||||||
#endif
|
#endif
|
||||||
guard let surface = surface else { return }
|
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) {
|
override func rightMouseDown(with event: NSEvent) {
|
||||||
|
|
@ -6590,6 +6683,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||||
guard let surface = surface else { return }
|
guard let surface = surface else { return }
|
||||||
let point = convert(event.locationInWindow, from: nil)
|
let point = convert(event.locationInWindow, from: nil)
|
||||||
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
|
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) {
|
override func mouseEntered(with event: NSEvent) {
|
||||||
|
|
@ -6618,6 +6712,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||||
}
|
}
|
||||||
|
|
||||||
override func mouseExited(with event: NSEvent) {
|
override func mouseExited(with event: NSEvent) {
|
||||||
|
if wordPathHoverActive {
|
||||||
|
wordPathHoverActive = false
|
||||||
|
NSCursor.pop()
|
||||||
|
}
|
||||||
guard let surface = surface else { return }
|
guard let surface = surface else { return }
|
||||||
if NSEvent.pressedMouseButtons != 0 {
|
if NSEvent.pressedMouseButtons != 0 {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -3879,6 +3879,50 @@ enum TelemetrySettings {
|
||||||
static let enabledForCurrentLaunch = isEnabled()
|
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 {
|
struct SettingsView: View {
|
||||||
private let contentTopInset: CGFloat = 8
|
private let contentTopInset: CGFloat = 8
|
||||||
private let pickerColumnWidth: CGFloat = 196
|
private let pickerColumnWidth: CGFloat = 196
|
||||||
|
|
@ -3894,6 +3938,7 @@ struct SettingsView: View {
|
||||||
private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled
|
private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled
|
||||||
@AppStorage(TelemetrySettings.sendAnonymousTelemetryKey)
|
@AppStorage(TelemetrySettings.sendAnonymousTelemetryKey)
|
||||||
private var sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry
|
private var sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry
|
||||||
|
@AppStorage(PreferredEditorSettings.key) private var preferredEditorCommand = ""
|
||||||
@AppStorage("cmuxPortBase") private var cmuxPortBase = 9100
|
@AppStorage("cmuxPortBase") private var cmuxPortBase = 9100
|
||||||
@AppStorage("cmuxPortRange") private var cmuxPortRange = 10
|
@AppStorage("cmuxPortRange") private var cmuxPortRange = 10
|
||||||
@AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
|
@AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
|
||||||
|
|
@ -4591,6 +4636,20 @@ struct SettingsView: View {
|
||||||
|
|
||||||
SettingsCardDivider()
|
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(
|
SettingsCardRow(
|
||||||
String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"),
|
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.")
|
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
|
socketControlMode = SocketControlSettings.defaultMode.rawValue
|
||||||
claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled
|
claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled
|
||||||
sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry
|
sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry
|
||||||
|
preferredEditorCommand = ""
|
||||||
browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
|
browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
|
||||||
browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
|
browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
|
||||||
browserThemeMode = BrowserThemeSettings.defaultMode.rawValue
|
browserThemeMode = BrowserThemeSettings.defaultMode.rawValue
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue