-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat: Shift+click to open links in system browser #2909
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3257,6 +3257,15 @@ class GhosttyApp { | |||||||||||||||||||
| #endif | ||||||||||||||||||||
| return false | ||||||||||||||||||||
| } | ||||||||||||||||||||
| // Shift+click: bypass embedded browser, open in system default browser. | ||||||||||||||||||||
| if NSEvent.modifierFlags.contains(.shift) { | ||||||||||||||||||||
| #if DEBUG | ||||||||||||||||||||
| dlog("link.openURL shift=true, forcing system browser url=\(target.url)") | ||||||||||||||||||||
| #endif | ||||||||||||||||||||
| return performOnMain { | ||||||||||||||||||||
| NSWorkspace.shared.open(target.url) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
| if !BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser() { | ||||||||||||||||||||
| #if DEBUG | ||||||||||||||||||||
| dlog("link.openURL cmuxBrowser=disabled, opening externally url=\(target.url)") | ||||||||||||||||||||
|
|
@@ -7044,7 +7053,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { | |||||||||||||||||||
| var keyEvent = ghostty_input_key_s() | ||||||||||||||||||||
| keyEvent.action = action | ||||||||||||||||||||
| keyEvent.keycode = UInt32(event.keyCode) | ||||||||||||||||||||
| keyEvent.mods = modsFromEvent(event) | ||||||||||||||||||||
| keyEvent.mods = linkModsFromEvent(event) | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The stripping is only necessary for the mouse paths ( |
||||||||||||||||||||
| keyEvent.consumed_mods = GHOSTTY_MODS_NONE | ||||||||||||||||||||
| keyEvent.text = nil | ||||||||||||||||||||
| keyEvent.composing = false | ||||||||||||||||||||
|
|
@@ -7098,11 +7107,24 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { | |||||||||||||||||||
| return ghostty_surface_has_selection(surface) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Strip Shift when Cmd is held so Ghostty treats Cmd+Shift+click as a | ||||||||||||||||||||
| // link click. The real Shift state is checked in the OPEN_URL handler. | ||||||||||||||||||||
| private func linkModsFromEvent(_ event: NSEvent) -> ghostty_input_mods_e { | ||||||||||||||||||||
| let flags = event.modifierFlags | ||||||||||||||||||||
| if flags.contains(.command), flags.contains(.shift) { | ||||||||||||||||||||
| return modsFromFlags(flags.subtracting(.shift)) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| return modsFromFlags(flags) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| private func hoverModsFromFlags( | ||||||||||||||||||||
| _ flags: NSEvent.ModifierFlags, | ||||||||||||||||||||
| suppressCommandPathHover: Bool | ||||||||||||||||||||
| ) -> ghostty_input_mods_e { | ||||||||||||||||||||
| let effectiveFlags = suppressCommandPathHover ? flags.subtracting(.command) : flags | ||||||||||||||||||||
| var effectiveFlags = suppressCommandPathHover ? flags.subtracting(.command) : flags | ||||||||||||||||||||
| if effectiveFlags.contains(.command), effectiveFlags.contains(.shift) { | ||||||||||||||||||||
| effectiveFlags = effectiveFlags.subtracting(.shift) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| #if DEBUG | ||||||||||||||||||||
| if suppressCommandPathHover, flags.contains(.command) { | ||||||||||||||||||||
| _ = CmuxUITestCapture.mutateJSONObjectIfConfigured( | ||||||||||||||||||||
|
|
@@ -7383,9 +7405,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { | |||||||||||||||||||
| // Only update mouse position on the first click to prevent unwanted cursor | ||||||||||||||||||||
| // movement during double-click selection (issue #1698) | ||||||||||||||||||||
| if event.clickCount == 1 { | ||||||||||||||||||||
| ghostty_surface_mouse_pos(surface, eventPoint.x, bounds.height - eventPoint.y, modsFromEvent(event)) | ||||||||||||||||||||
| ghostty_surface_mouse_pos(surface, eventPoint.x, bounds.height - eventPoint.y, linkModsFromEvent(event)) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, modsFromEvent(event)) | ||||||||||||||||||||
| _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, linkModsFromEvent(event)) | ||||||||||||||||||||
|
Comment on lines
+7408
to
+7410
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| override func mouseUp(with event: NSEvent) { | ||||||||||||||||||||
|
|
@@ -7394,7 +7416,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { | |||||||||||||||||||
| #endif | ||||||||||||||||||||
| guard let surface = surface else { return } | ||||||||||||||||||||
| let point = convert(event.locationInWindow, from: nil) | ||||||||||||||||||||
| let consumed = 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, linkModsFromEvent(event)) | ||||||||||||||||||||
| _ = handleCommandClickRelease(at: point, modifierFlags: event.modifierFlags, ghosttyConsumed: consumed) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6171,6 +6171,18 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { | |
| return | ||
| } | ||
|
|
||
| // Shift+click: bypass embedded browser and open in system default browser. | ||
| if navigationAction.modifierFlags.contains(.shift), | ||
| navigationAction.navigationType == .linkActivated, | ||
| let url = navigationAction.request.url { | ||
| #if DEBUG | ||
| dlog("browser.nav.decidePolicy.action kind=shiftClickExternal url=\(url.absoluteString)") | ||
| #endif | ||
| NSWorkspace.shared.open(url) | ||
| decisionHandler(.cancel) | ||
| return | ||
| } | ||
|
Comment on lines
+6174
to
+6184
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle failed external-open gracefully before canceling navigation. If 💡 Suggested fix if navigationAction.modifierFlags.contains(.shift),
navigationAction.navigationType == .linkActivated,
let url = navigationAction.request.url {
`#if` DEBUG
dlog("browser.nav.decidePolicy.action kind=shiftClickExternal url=\(url.absoluteString)")
`#endif`
- NSWorkspace.shared.open(url)
- decisionHandler(.cancel)
- return
+ let opened = NSWorkspace.shared.open(url)
+ if opened {
+ decisionHandler(.cancel)
+ return
+ }
+ decisionHandler(.allow)
+ return
}🤖 Prompt for AI Agents |
||
|
|
||
| // Cmd+click and middle-click on regular links should always open in a new tab. | ||
| if shouldOpenInNewTab, | ||
| let url = navigationAction.request.url { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -568,6 +568,17 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { | |
| return | ||
| } | ||
|
|
||
| // Shift+click: bypass embedded browser and open in system default browser. | ||
| if navigationAction.modifierFlags.contains(.shift), | ||
| navigationAction.navigationType == .linkActivated { | ||
| #if DEBUG | ||
| dlog("popup.nav.shiftClickExternal url=\(url.absoluteString)") | ||
| #endif | ||
| NSWorkspace.shared.open(url) | ||
| decisionHandler(.cancel) | ||
| return | ||
| } | ||
|
Comment on lines
+571
to
+580
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Shift+click check here fires before the insecure HTTP guard (line 583), so Shift+clicking an |
||
|
|
||
| // Insecure HTTP → show same prompt as main browser | ||
| if browserShouldBlockInsecureHTTPURL(url) { | ||
| #if DEBUG | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
In AppKit, doesNSEvent.modifierFlagsreturn the current global modifier state, or the modifier flags from the event that triggered a later callback?💡 Result:
In AppKit, NSEvent.modifierFlags (the instance property on an NSEvent object) returns the modifier flags from the specific event that triggered the callback, not the current global modifier state.
Citations:
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 2054
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 4608
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 5577
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 2400
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 156
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 2723
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 128
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 243
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 1256
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 1915
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 4667
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 330
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 482
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 42
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 102
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 42
Latch the click's Shift bit instead of reading global modifier state.
Line 3262 uses
NSEvent.modifierFlags(the class property), which returns the current global modifier state, not the modifiers from the original click event. ThehandleActioncallback receives only aghostty_action_sstruct—the originalNSEventis unavailable. If Shift is released betweenmouseUpand this callback, the same Cmd+Shift+click can fall back to the embedded browser instead of opening externally.Capture the Shift state from the event during
mouseDown/mouseUpand propagate it through the Ghostty callback chain (or store it temporarily) so this check reads the latched value instead of global state.🤖 Prompt for AI Agents