detached c20d7cc

feat(webkit): add middle-click and Ctrl+click to open links in new panes

- Fix popup handler registration timing by connecting signals immediately
  instead of deferring via glib.IdleAdd, resolving g_object_ref assertion
  errors when WebKit takes ownership of popup WebViews
- Implement navigation policy handler using decide-policy signal to
  intercept middle-click (button 2) and Ctrl+left-click before navigation
  occurs, allowing links to open in new workspace panes
- Add RegisterMiddleClickLinkHandler callback infrastructure to WebView
- Wire handler to WorkspaceManager to create new split panes respecting
  workspace.popups.placement configuration
- Remove incorrect context-menu approach that was too late in event chain
- Clean up mouse gesture handling code

This enables standard browser behavior where middle-click or Ctrl+click
on links opens them in new panes (split right by default), while regular
clicks continue to navigate in the current pane.
commit
c20d7cc9291419a23ae7b4e43a55d6c5e9a42a73
author
bnema · · 2025-11-13 06:10 UTC
parent
657b57f
signature
unsigned
4 files · +147 -31
M internal/app/browser/workspace_manager.go +42 -0
@@ -457,5 +457,47 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
457457 return wm.paneModeActive
458458 })
459459
460+ // Register middle-click link handler to open links in new panes
461+ webView.RegisterMiddleClickLinkHandler(func(linkURL string) bool {
462+ if wm == nil {
463+ return false
464+ }
465+
466+ log.Printf("[workspace] Middle-click on link, opening in new pane: %s", linkURL)
467+
468+ // Get the node for this webview
469+ node := wm.GetNodeForWebView(webView)
470+ if node == nil {
471+ log.Printf("[workspace] Cannot find node for middle-clicked link")
472+ return false
473+ }
474+
475+ // Get split direction from popup placement config or use right as default
476+ direction := "right"
477+ if wm.app != nil && wm.app.config != nil && wm.app.config.Workspace.Popups.Placement != "" {
478+ direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
479+ }
480+
481+ // Split the pane
482+ newNode, err := wm.SplitPane(node, direction)
483+ if err != nil {
484+ log.Printf("[workspace] Failed to split pane for middle-click: %v", err)
485+ return false
486+ }
487+
488+ // Navigate the new pane to the URL
489+ if newNode != nil && newNode.pane != nil && newNode.pane.WebView() != nil {
490+ if err := newNode.pane.WebView().LoadURL(linkURL); err != nil {
491+ log.Printf("[workspace] Failed to load URL in new pane: %v", err)
492+ return false
493+ }
494+
495+ // Set the new pane as active
496+ wm.SetActivePane(newNode, SourceProgrammatic)
497+ }
498+
499+ return true // Indicate we handled the click
500+ })
501+
460502 log.Printf("[workspace] Registered navigation handler for webview: %d", webView.ID())
461503 }
@@ -457,5 +457,47 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
457 return wm.paneModeActive457 return wm.paneModeActive
458 })458 })
459 459
460+ // Register middle-click link handler to open links in new panes
461+ webView.RegisterMiddleClickLinkHandler(func(linkURL string) bool {
462+ if wm == nil {
463+ return false
464+ }
465+
466+ log.Printf("[workspace] Middle-click on link, opening in new pane: %s", linkURL)
467+
468+ // Get the node for this webview
469+ node := wm.GetNodeForWebView(webView)
470+ if node == nil {
471+ log.Printf("[workspace] Cannot find node for middle-clicked link")
472+ return false
473+ }
474+
475+ // Get split direction from popup placement config or use right as default
476+ direction := "right"
477+ if wm.app != nil && wm.app.config != nil && wm.app.config.Workspace.Popups.Placement != "" {
478+ direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
479+ }
480+
481+ // Split the pane
482+ newNode, err := wm.SplitPane(node, direction)
483+ if err != nil {
484+ log.Printf("[workspace] Failed to split pane for middle-click: %v", err)
485+ return false
486+ }
487+
488+ // Navigate the new pane to the URL
489+ if newNode != nil && newNode.pane != nil && newNode.pane.WebView() != nil {
490+ if err := newNode.pane.WebView().LoadURL(linkURL); err != nil {
491+ log.Printf("[workspace] Failed to load URL in new pane: %v", err)
492+ return false
493+ }
494+
495+ // Set the new pane as active
496+ wm.SetActivePane(newNode, SourceProgrammatic)
497+ }
498+
499+ return true // Indicate we handled the click
500+ })
501+
460 log.Printf("[workspace] Registered navigation handler for webview: %d", webView.ID())502 log.Printf("[workspace] Registered navigation handler for webview: %d", webView.ID())
461 }503 }
M internal/app/browser/workspace_popup.go +16 -25
@@ -9,7 +9,6 @@ import (
99 "github.com/bnema/dumber/internal/config"
1010 "github.com/bnema/dumber/pkg/webkit"
1111 webkitgtk "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
12- "github.com/diamondburned/gotk4/pkg/glib/v2"
1312 )
1413
1514 const (
@@ -342,34 +341,26 @@ func (wm *WorkspaceManager) setupPopupHandling(webView *webkit.WebView, parentNo
342341 }
343342 log.Printf("[workspace] Step 3 complete: stored in pendingPopups")
344343
345- // CRITICAL: Do NOT connect signals before returning to WebKit
346- // Connecting signals might trigger WebKit to access WindowFeatures before it's set
347- // Instead, we'll connect them after WebKit has processed the return
348- log.Printf("[workspace] Step 4: Scheduling signal connections for next main loop iteration (AFTER WebKit configures the view)")
344+ // Connect signals immediately BEFORE returning to WebKit
345+ // This is safe because we're only connecting signal handlers, not accessing properties
346+ // WebKit will configure WindowFeatures AFTER this callback returns, which is fine
347+ log.Printf("[workspace] Step 4: Connecting signals immediately before returning to WebKit")
349348
350- // Use GLib idle_add to connect signals AFTER this callback returns
351- // This ensures WebKit has finished configuring WindowFeatures before we touch the view
352- glib.IdleAdd(func() bool {
353- log.Printf("[workspace] Idle callback: Now connecting signals for popup ID=%d", popupID)
354-
355- // Connect ready-to-show signal NOW (after WebKit has configured the view)
356- barePopupView.ConnectReadyToShow(func() {
357- log.Printf("[workspace] ready-to-show signal received for popup ID=%d", popupID)
358- wm.handlePopupReadyToShow(popupID)
359- })
360- log.Printf("[workspace] Idle callback: ready-to-show connected")
361-
362- // Connect close handler
363- barePopupView.ConnectClose(func() {
364- log.Printf("[workspace] close signal received for popup ID=%d", popupID)
365- wm.handlePopupClose(popupID)
366- })
367- log.Printf("[workspace] Idle callback: close signal connected")
349+ // Connect ready-to-show signal
350+ barePopupView.ConnectReadyToShow(func() {
351+ log.Printf("[workspace] ready-to-show signal received for popup ID=%d", popupID)
352+ wm.handlePopupReadyToShow(popupID)
353+ })
354+ log.Printf("[workspace] Connected ready-to-show signal for popup ID=%d", popupID)
368355
369- return false // Don't repeat
356+ // Connect close handler
357+ barePopupView.ConnectClose(func() {
358+ log.Printf("[workspace] close signal received for popup ID=%d", popupID)
359+ wm.handlePopupClose(popupID)
370360 })
361+ log.Printf("[workspace] Connected close signal for popup ID=%d", popupID)
371362
372- log.Printf("[workspace] Step 4 complete: signal connections scheduled")
363+ log.Printf("[workspace] Step 4 complete: signals connected")
373364
374365 log.Printf("[workspace] Step 5: Returning wrapped view to WebKit (will extract bare view in ConnectCreate)")
375366
@@ -9,7 +9,6 @@ import (
9 "github.com/bnema/dumber/internal/config"9 "github.com/bnema/dumber/internal/config"
10 "github.com/bnema/dumber/pkg/webkit"10 "github.com/bnema/dumber/pkg/webkit"
11 webkitgtk "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"11 webkitgtk "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
12- "github.com/diamondburned/gotk4/pkg/glib/v2"
13 )12 )
14 13
15 const (14 const (
@@ -342,34 +341,26 @@ func (wm *WorkspaceManager) setupPopupHandling(webView *webkit.WebView, parentNo
342 }341 }
343 log.Printf("[workspace] Step 3 complete: stored in pendingPopups")342 log.Printf("[workspace] Step 3 complete: stored in pendingPopups")
344 343
345- // CRITICAL: Do NOT connect signals before returning to WebKit344+ // Connect signals immediately BEFORE returning to WebKit
346- // Connecting signals might trigger WebKit to access WindowFeatures before it's set345+ // This is safe because we're only connecting signal handlers, not accessing properties
347- // Instead, we'll connect them after WebKit has processed the return346+ // WebKit will configure WindowFeatures AFTER this callback returns, which is fine
348- log.Printf("[workspace] Step 4: Scheduling signal connections for next main loop iteration (AFTER WebKit configures the view)")347+ log.Printf("[workspace] Step 4: Connecting signals immediately before returning to WebKit")
349 348
350- // Use GLib idle_add to connect signals AFTER this callback returns349+ // Connect ready-to-show signal
351- // This ensures WebKit has finished configuring WindowFeatures before we touch the view350+ barePopupView.ConnectReadyToShow(func() {
352- glib.IdleAdd(func() bool {351+ log.Printf("[workspace] ready-to-show signal received for popup ID=%d", popupID)
353- log.Printf("[workspace] Idle callback: Now connecting signals for popup ID=%d", popupID)352+ wm.handlePopupReadyToShow(popupID)
354-353+ })
355- // Connect ready-to-show signal NOW (after WebKit has configured the view)354+ log.Printf("[workspace] Connected ready-to-show signal for popup ID=%d", popupID)
356- barePopupView.ConnectReadyToShow(func() {
357- log.Printf("[workspace] ready-to-show signal received for popup ID=%d", popupID)
358- wm.handlePopupReadyToShow(popupID)
359- })
360- log.Printf("[workspace] Idle callback: ready-to-show connected")
361-
362- // Connect close handler
363- barePopupView.ConnectClose(func() {
364- log.Printf("[workspace] close signal received for popup ID=%d", popupID)
365- wm.handlePopupClose(popupID)
366- })
367- log.Printf("[workspace] Idle callback: close signal connected")
368 355
369- return false // Don't repeat356+ // Connect close handler
357+ barePopupView.ConnectClose(func() {
358+ log.Printf("[workspace] close signal received for popup ID=%d", popupID)
359+ wm.handlePopupClose(popupID)
370 })360 })
361+ log.Printf("[workspace] Connected close signal for popup ID=%d", popupID)
371 362
372- log.Printf("[workspace] Step 4 complete: signal connections scheduled")363+ log.Printf("[workspace] Step 4 complete: signals connected")
373 364
374 log.Printf("[workspace] Step 5: Returning wrapped view to WebKit (will extract bare view in ConnectCreate)")365 log.Printf("[workspace] Step 5: Returning wrapped view to WebKit (will extract bare view in ConnectCreate)")
375 366
M pkg/webkit/mouse_gestures.go +1 -6
@@ -42,7 +42,6 @@ func (w *WebView) AttachMouseGestures() {
4242
4343 switch button {
4444 case mouseButtonBack:
45- // Mouse back button - navigate backward
4645 if w.view.CanGoBack() {
4746 log.Printf("[mouse-gestures] Back button clicked - navigating backward")
4847 w.view.GoBack()
@@ -51,21 +50,17 @@ func (w *WebView) AttachMouseGestures() {
5150 }
5251
5352 case mouseButtonForward:
54- // Mouse forward button - navigate forward
5553 if w.view.CanGoForward() {
5654 log.Printf("[mouse-gestures] Forward button clicked - navigating forward")
5755 w.view.GoForward()
5856 } else {
5957 log.Printf("[mouse-gestures] Forward button clicked but cannot go forward")
6058 }
61-
62- default:
63- // Don't handle other buttons (let WebView handle normally)
64- return
6559 }
6660 })
6761
6862 // Attach controller to WebView
6963 w.view.AddController(gestureClick)
64+
7065 log.Printf("[mouse-gestures] Mouse gesture controller attached to WebView ID %d", w.id)
7166 }
@@ -42,7 +42,6 @@ func (w *WebView) AttachMouseGestures() {
42 42
43 switch button {43 switch button {
44 case mouseButtonBack:44 case mouseButtonBack:
45- // Mouse back button - navigate backward
46 if w.view.CanGoBack() {45 if w.view.CanGoBack() {
47 log.Printf("[mouse-gestures] Back button clicked - navigating backward")46 log.Printf("[mouse-gestures] Back button clicked - navigating backward")
48 w.view.GoBack()47 w.view.GoBack()
@@ -51,21 +50,17 @@ func (w *WebView) AttachMouseGestures() {
51 }50 }
52 51
53 case mouseButtonForward:52 case mouseButtonForward:
54- // Mouse forward button - navigate forward
55 if w.view.CanGoForward() {53 if w.view.CanGoForward() {
56 log.Printf("[mouse-gestures] Forward button clicked - navigating forward")54 log.Printf("[mouse-gestures] Forward button clicked - navigating forward")
57 w.view.GoForward()55 w.view.GoForward()
58 } else {56 } else {
59 log.Printf("[mouse-gestures] Forward button clicked but cannot go forward")57 log.Printf("[mouse-gestures] Forward button clicked but cannot go forward")
60 }58 }
61-
62- default:
63- // Don't handle other buttons (let WebView handle normally)
64- return
65 }59 }
66 })60 })
67 61
68 // Attach controller to WebView62 // Attach controller to WebView
69 w.view.AddController(gestureClick)63 w.view.AddController(gestureClick)
64+
70 log.Printf("[mouse-gestures] Mouse gesture controller attached to WebView ID %d", w.id)65 log.Printf("[mouse-gestures] Mouse gesture controller attached to WebView ID %d", w.id)
71 }66 }
M pkg/webkit/webview.go +88 -0
@@ -46,6 +46,7 @@ type WebView struct {
4646 onWorkspaceNavigation func(direction string) bool // Workspace pane navigation
4747 onPaneModeShortcut func(action string) bool // Pane mode shortcuts (enter, actions, exit)
4848 isPaneModeActive func() bool // Check if pane mode is active
49+ onMiddleClickLink func(url string) bool // Middle mouse click on link handler
4950 }
5051
5152 // NewWebView creates a new WebView with the given configuration
@@ -290,6 +291,85 @@ func (w *WebView) setupEventHandlers() {
290291 }
291292 })
292293 }
294+
295+ // Setup navigation policy handler for middle-click and Ctrl+click interception
296+ w.setupNavigationPolicyHandler()
297+}
298+
299+// setupNavigationPolicyHandler sets up the decide-policy signal handler
300+// This intercepts navigation actions BEFORE they occur, allowing us to detect
301+// middle-click and Ctrl+click on links and open them in new panes
302+func (w *WebView) setupNavigationPolicyHandler() {
303+ w.view.ConnectDecidePolicy(func(decision webkit.PolicyDecisioner, decisionType webkit.PolicyDecisionType) bool {
304+ // Only handle navigation actions (not new window or response policies)
305+ if decisionType != webkit.PolicyDecisionTypeNavigationAction {
306+ return false // Let WebKit handle
307+ }
308+
309+ // Cast to NavigationPolicyDecision to access NavigationAction
310+ navDecision, ok := decision.(*webkit.NavigationPolicyDecision)
311+ if !ok {
312+ return false
313+ }
314+
315+ navAction := navDecision.NavigationAction()
316+ if navAction == nil {
317+ return false
318+ }
319+
320+ // Check if this is a link click
321+ if navAction.NavigationType() != webkit.NavigationTypeLinkClicked {
322+ return false // Not a link click, let WebKit handle normally
323+ }
324+
325+ // Get mouse button and modifiers
326+ mouseButton := navAction.MouseButton()
327+ modifiers := navAction.Modifiers()
328+
329+ // Check for middle-click (button 2) or Ctrl+left-click (button 1 + Ctrl)
330+ isMiddleClick := (mouseButton == 2)
331+ isCtrlClick := (mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMask) != 0)
332+
333+ if !isMiddleClick && !isCtrlClick {
334+ return false // Normal click, let WebKit handle
335+ }
336+
337+ // Get the link URL
338+ request := navAction.Request()
339+ if request == nil {
340+ return false
341+ }
342+
343+ linkURL := request.URI()
344+ if linkURL == "" {
345+ return false
346+ }
347+
348+ clickType := "middle-click"
349+ if isCtrlClick {
350+ clickType = "Ctrl+click"
351+ }
352+ log.Printf("[navigation-policy] Detected %s on link: %s", clickType, linkURL)
353+
354+ // Call the registered handler
355+ w.mu.RLock()
356+ handler := w.onMiddleClickLink
357+ w.mu.RUnlock()
358+
359+ if handler != nil {
360+ handled := handler(linkURL)
361+ if handled {
362+ log.Printf("[navigation-policy] %s handled by workspace, blocking navigation", clickType)
363+ // Block the navigation by calling Ignore()
364+ navDecision.Ignore()
365+ return true // Prevent default behavior
366+ }
367+ }
368+
369+ return false // Let WebKit handle if not handled
370+ })
371+
372+ log.Printf("[webkit] Navigation policy handler attached to WebView ID %d", w.id)
293373 }
294374
295375 // LoadURL loads the given URL in the WebView
@@ -565,6 +645,14 @@ func (w *WebView) OnWindowTypeDetected(handler func(WindowType, *WindowFeatures)
565645 w.onWindowTypeDetected = handler
566646 }
567647
648+// RegisterMiddleClickLinkHandler registers a handler for middle mouse button clicks on links
649+// The handler receives the link URL and returns true if handled
650+func (w *WebView) RegisterMiddleClickLinkHandler(handler func(url string) bool) {
651+ w.mu.Lock()
652+ defer w.mu.Unlock()
653+ w.onMiddleClickLink = handler
654+}
655+
568656 // RunOnMainThread executes a function on the GTK main thread
569657 func (w *WebView) RunOnMainThread(fn func()) {
570658 RunOnMainThread(fn)
@@ -46,6 +46,7 @@ type WebView struct {
46 onWorkspaceNavigation func(direction string) bool // Workspace pane navigation46 onWorkspaceNavigation func(direction string) bool // Workspace pane navigation
47 onPaneModeShortcut func(action string) bool // Pane mode shortcuts (enter, actions, exit)47 onPaneModeShortcut func(action string) bool // Pane mode shortcuts (enter, actions, exit)
48 isPaneModeActive func() bool // Check if pane mode is active48 isPaneModeActive func() bool // Check if pane mode is active
49+ onMiddleClickLink func(url string) bool // Middle mouse click on link handler
49 }50 }
50 51
51 // NewWebView creates a new WebView with the given configuration52 // NewWebView creates a new WebView with the given configuration
@@ -290,6 +291,85 @@ func (w *WebView) setupEventHandlers() {
290 }291 }
291 })292 })
292 }293 }
294+
295+ // Setup navigation policy handler for middle-click and Ctrl+click interception
296+ w.setupNavigationPolicyHandler()
297+}
298+
299+// setupNavigationPolicyHandler sets up the decide-policy signal handler
300+// This intercepts navigation actions BEFORE they occur, allowing us to detect
301+// middle-click and Ctrl+click on links and open them in new panes
302+func (w *WebView) setupNavigationPolicyHandler() {
303+ w.view.ConnectDecidePolicy(func(decision webkit.PolicyDecisioner, decisionType webkit.PolicyDecisionType) bool {
304+ // Only handle navigation actions (not new window or response policies)
305+ if decisionType != webkit.PolicyDecisionTypeNavigationAction {
306+ return false // Let WebKit handle
307+ }
308+
309+ // Cast to NavigationPolicyDecision to access NavigationAction
310+ navDecision, ok := decision.(*webkit.NavigationPolicyDecision)
311+ if !ok {
312+ return false
313+ }
314+
315+ navAction := navDecision.NavigationAction()
316+ if navAction == nil {
317+ return false
318+ }
319+
320+ // Check if this is a link click
321+ if navAction.NavigationType() != webkit.NavigationTypeLinkClicked {
322+ return false // Not a link click, let WebKit handle normally
323+ }
324+
325+ // Get mouse button and modifiers
326+ mouseButton := navAction.MouseButton()
327+ modifiers := navAction.Modifiers()
328+
329+ // Check for middle-click (button 2) or Ctrl+left-click (button 1 + Ctrl)
330+ isMiddleClick := (mouseButton == 2)
331+ isCtrlClick := (mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMask) != 0)
332+
333+ if !isMiddleClick && !isCtrlClick {
334+ return false // Normal click, let WebKit handle
335+ }
336+
337+ // Get the link URL
338+ request := navAction.Request()
339+ if request == nil {
340+ return false
341+ }
342+
343+ linkURL := request.URI()
344+ if linkURL == "" {
345+ return false
346+ }
347+
348+ clickType := "middle-click"
349+ if isCtrlClick {
350+ clickType = "Ctrl+click"
351+ }
352+ log.Printf("[navigation-policy] Detected %s on link: %s", clickType, linkURL)
353+
354+ // Call the registered handler
355+ w.mu.RLock()
356+ handler := w.onMiddleClickLink
357+ w.mu.RUnlock()
358+
359+ if handler != nil {
360+ handled := handler(linkURL)
361+ if handled {
362+ log.Printf("[navigation-policy] %s handled by workspace, blocking navigation", clickType)
363+ // Block the navigation by calling Ignore()
364+ navDecision.Ignore()
365+ return true // Prevent default behavior
366+ }
367+ }
368+
369+ return false // Let WebKit handle if not handled
370+ })
371+
372+ log.Printf("[webkit] Navigation policy handler attached to WebView ID %d", w.id)
293 }373 }
294 374
295 // LoadURL loads the given URL in the WebView375 // LoadURL loads the given URL in the WebView
@@ -565,6 +645,14 @@ func (w *WebView) OnWindowTypeDetected(handler func(WindowType, *WindowFeatures)
565 w.onWindowTypeDetected = handler645 w.onWindowTypeDetected = handler
566 }646 }
567 647
648+// RegisterMiddleClickLinkHandler registers a handler for middle mouse button clicks on links
649+// The handler receives the link URL and returns true if handled
650+func (w *WebView) RegisterMiddleClickLinkHandler(handler func(url string) bool) {
651+ w.mu.Lock()
652+ defer w.mu.Unlock()
653+ w.onMiddleClickLink = handler
654+}
655+
568 // RunOnMainThread executes a function on the GTK main thread656 // RunOnMainThread executes a function on the GTK main thread
569 func (w *WebView) RunOnMainThread(fn func()) {657 func (w *WebView) RunOnMainThread(fn func()) {
570 RunOnMainThread(fn)658 RunOnMainThread(fn)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close