detached a13dfa8

Merge pull request #156 from bnema/next

fix: handle external URL schemes and improve navigation
commit
a13dfa8e69b7a59de3703b689a318290a41a275c
author
bnema · · 2026-01-29 05:29 UTC
committer
GitHub · · 2026-01-29 05:29 UTC
merge of
95707f3 + b03b1e9 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
7 files · +431 -70
A AGENTS.md +33 -0
@@ -0,0 +1,33 @@
1+# Clean Architecture
2+
3+## Layers
4+
5+> **Note:** This document describes conceptual architecture layers. The actual project structure uses `internal/` prefixes (e.g., `internal/application/usecase/`, `internal/application/port/`, `internal/infrastructure/`) to organize the code.
6+
7+| Layer | Contains | Depends On |
8+|-------|----------|------------|
9+| `app/` | Entry points, CLI/TUI, request handling | usecase |
10+| `usecase/` | Business logic, orchestration | domain, boundaries |
11+| `domain/` | Entities, value objects, utils | nothing |
12+| `boundaries/in` | Inbound interfaces (services) | domain |
13+| `boundaries/out` | Outbound interfaces (repos, clients) | domain |
14+| `adapters/` | Interface implementations | boundaries, domain |
15+
16+## Rules
17+
18+- **app**: Parse input, call usecase, format output. No business logic.
19+- **usecase**: All business logic. Use boundaries for I/O.
20+- **domain**: Pure functions only. No I/O.
21+- **boundaries**: Interfaces only. Mock with `mockery generate`.
22+
23+## Anti-patterns strictly forbidden
24+
25+```text
26+❌ app doing business logic
27+❌ app calling adapters directly
28+❌ usecase importing adapters
29+❌ domain importing anything
30+```
31+
32+## Commands
33+
@@ -0,0 +1,33 @@
1+# Clean Architecture
2+
3+## Layers
4+
5+> **Note:** This document describes conceptual architecture layers. The actual project structure uses `internal/` prefixes (e.g., `internal/application/usecase/`, `internal/application/port/`, `internal/infrastructure/`) to organize the code.
6+
7+| Layer | Contains | Depends On |
8+|-------|----------|------------|
9+| `app/` | Entry points, CLI/TUI, request handling | usecase |
10+| `usecase/` | Business logic, orchestration | domain, boundaries |
11+| `domain/` | Entities, value objects, utils | nothing |
12+| `boundaries/in` | Inbound interfaces (services) | domain |
13+| `boundaries/out` | Outbound interfaces (repos, clients) | domain |
14+| `adapters/` | Interface implementations | boundaries, domain |
15+
16+## Rules
17+
18+- **app**: Parse input, call usecase, format output. No business logic.
19+- **usecase**: All business logic. Use boundaries for I/O.
20+- **domain**: Pure functions only. No I/O.
21+- **boundaries**: Interfaces only. Mock with `mockery generate`.
22+
23+## Anti-patterns strictly forbidden
24+
25+```text
26+❌ app doing business logic
27+❌ app calling adapters directly
28+❌ usecase importing adapters
29+❌ domain importing anything
30+```
31+
32+## Commands
33+
M internal/domain/url/normalize.go +22 -0
@@ -181,3 +181,25 @@ func TrimLeadingSpacesIfURL(input string) string {
181181 }
182182 return input
183183 }
184+
185+// IsExternalScheme returns true if the URL uses a scheme that should be
186+// launched externally (e.g., vscode://, vscode-insiders://, spotify://, steam://)
187+// rather than handled by the browser.
188+// URL schemes are case-insensitive per RFC 3986.
189+func IsExternalScheme(uri string) bool {
190+ if uri == "" {
191+ return false
192+ }
193+
194+ parsed, err := url.Parse(uri)
195+ if err != nil || parsed.Scheme == "" {
196+ return false
197+ }
198+
199+ switch strings.ToLower(parsed.Scheme) {
200+ case "http", "https", "file", "dumb", "about", "data", "blob", "javascript":
201+ return false
202+ default:
203+ return true
204+ }
205+}
@@ -181,3 +181,25 @@ func TrimLeadingSpacesIfURL(input string) string {
181 }181 }
182 return input182 return input
183 }183 }
184+
185+// IsExternalScheme returns true if the URL uses a scheme that should be
186+// launched externally (e.g., vscode://, vscode-insiders://, spotify://, steam://)
187+// rather than handled by the browser.
188+// URL schemes are case-insensitive per RFC 3986.
189+func IsExternalScheme(uri string) bool {
190+ if uri == "" {
191+ return false
192+ }
193+
194+ parsed, err := url.Parse(uri)
195+ if err != nil || parsed.Scheme == "" {
196+ return false
197+ }
198+
199+ switch strings.ToLower(parsed.Scheme) {
200+ case "http", "https", "file", "dumb", "about", "data", "blob", "javascript":
201+ return false
202+ default:
203+ return true
204+ }
205+}
M internal/domain/url/normalize_test.go +108 -0
@@ -401,3 +401,111 @@ func TestLooksLikeURL(t *testing.T) {
401401 })
402402 }
403403 }
404+
405+func TestIsExternalScheme(t *testing.T) {
406+ tests := []struct {
407+ name string
408+ input string
409+ want bool
410+ }{
411+ {
412+ name: "empty string",
413+ input: "",
414+ want: false,
415+ },
416+ {
417+ name: "http scheme",
418+ input: "http://example.com",
419+ want: false,
420+ },
421+ {
422+ name: "https scheme",
423+ input: "https://example.com",
424+ want: false,
425+ },
426+ {
427+ name: "file scheme",
428+ input: "file:///path/to/file.html",
429+ want: false,
430+ },
431+ {
432+ name: "dumb scheme",
433+ input: "dumb://home",
434+ want: false,
435+ },
436+ {
437+ name: "about scheme",
438+ input: "about:blank",
439+ want: false,
440+ },
441+ {
442+ name: "data scheme",
443+ input: "data:text/plain;base64,SGVsbG8gV29ybGQ==",
444+ want: false,
445+ },
446+ {
447+ name: "blob scheme",
448+ input: "blob:https://example.com/blob-id",
449+ want: false,
450+ },
451+ {
452+ name: "javascript scheme",
453+ input: "javascript:alert('hello')",
454+ want: false,
455+ },
456+ {
457+ name: "vscode scheme",
458+ input: "vscode://open",
459+ want: true,
460+ },
461+ {
462+ name: "spotify scheme",
463+ input: "spotify:track:123",
464+ want: true,
465+ },
466+ {
467+ name: "steam scheme",
468+ input: "steam://run/123",
469+ want: true,
470+ },
471+ {
472+ name: "uppercase HTTP scheme",
473+ input: "HTTP://example.com",
474+ want: false,
475+ },
476+ {
477+ name: "uppercase ABOUT scheme",
478+ input: "ABOUT:blank",
479+ want: false,
480+ },
481+ {
482+ name: "uppercase VSCODE scheme",
483+ input: "VSCODE://open",
484+ want: true,
485+ },
486+ {
487+ name: "malformed URL",
488+ input: "://invalid",
489+ want: false,
490+ },
491+ {
492+ name: "no scheme",
493+ input: "example.com",
494+ want: false,
495+ },
496+ {
497+ name: "scheme only",
498+ input: "custom://",
499+ want: true,
500+ },
501+ }
502+
503+ for _, tt := range tests {
504+ t.Run(tt.name, func(t *testing.T) {
505+ got := IsExternalScheme(tt.input)
506+ if got != tt.want {
507+ t.Errorf("IsExternalScheme(%q) = %v, want %v", tt.input, got, tt.want)
508+ }
509+ })
510+ }
511+}
@@ -401,3 +401,111 @@ func TestLooksLikeURL(t *testing.T) {
401 })401 })
402 }402 }
403 }403 }
404+
405+func TestIsExternalScheme(t *testing.T) {
406+ tests := []struct {
407+ name string
408+ input string
409+ want bool
410+ }{
411+ {
412+ name: "empty string",
413+ input: "",
414+ want: false,
415+ },
416+ {
417+ name: "http scheme",
418+ input: "http://example.com",
419+ want: false,
420+ },
421+ {
422+ name: "https scheme",
423+ input: "https://example.com",
424+ want: false,
425+ },
426+ {
427+ name: "file scheme",
428+ input: "file:///path/to/file.html",
429+ want: false,
430+ },
431+ {
432+ name: "dumb scheme",
433+ input: "dumb://home",
434+ want: false,
435+ },
436+ {
437+ name: "about scheme",
438+ input: "about:blank",
439+ want: false,
440+ },
441+ {
442+ name: "data scheme",
443+ input: "data:text/plain;base64,SGVsbG8gV29ybGQ==",
444+ want: false,
445+ },
446+ {
447+ name: "blob scheme",
448+ input: "blob:https://example.com/blob-id",
449+ want: false,
450+ },
451+ {
452+ name: "javascript scheme",
453+ input: "javascript:alert('hello')",
454+ want: false,
455+ },
456+ {
457+ name: "vscode scheme",
458+ input: "vscode://open",
459+ want: true,
460+ },
461+ {
462+ name: "spotify scheme",
463+ input: "spotify:track:123",
464+ want: true,
465+ },
466+ {
467+ name: "steam scheme",
468+ input: "steam://run/123",
469+ want: true,
470+ },
471+ {
472+ name: "uppercase HTTP scheme",
473+ input: "HTTP://example.com",
474+ want: false,
475+ },
476+ {
477+ name: "uppercase ABOUT scheme",
478+ input: "ABOUT:blank",
479+ want: false,
480+ },
481+ {
482+ name: "uppercase VSCODE scheme",
483+ input: "VSCODE://open",
484+ want: true,
485+ },
486+ {
487+ name: "malformed URL",
488+ input: "://invalid",
489+ want: false,
490+ },
491+ {
492+ name: "no scheme",
493+ input: "example.com",
494+ want: false,
495+ },
496+ {
497+ name: "scheme only",
498+ input: "custom://",
499+ want: true,
500+ },
501+ }
502+
503+ for _, tt := range tests {
504+ t.Run(tt.name, func(t *testing.T) {
505+ got := IsExternalScheme(tt.input)
506+ if got != tt.want {
507+ t.Errorf("IsExternalScheme(%q) = %v, want %v", tt.input, got, tt.want)
508+ }
509+ })
510+ }
511+}
M internal/infrastructure/desktop/adapter.go +19 -0
@@ -392,6 +392,25 @@ func NewSessionSpawner(ctx context.Context) *SessionSpawner {
392392 // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration.
393393 const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION"
394394
395+// LaunchExternalURL opens a URL with the system's default handler (xdg-open on Linux).
396+// Used for external URL schemes like vscode://, spotify://, steam://, etc.
397+func LaunchExternalURL(uri string) {
398+ cmd := exec.Command("xdg-open", uri)
399+ // Detach from current process - we don't care about the result
400+ cmd.Stdin = nil
401+ cmd.Stdout = nil
402+ cmd.Stderr = nil
403+ if err := cmd.Start(); err != nil {
404+ fmt.Fprintf(os.Stderr, "failed to launch external URL %q with xdg-open: %v\n", uri, err)
405+ return
406+ }
407+ if cmd.Process != nil {
408+ if err := cmd.Process.Release(); err != nil {
409+ fmt.Fprintf(os.Stderr, "failed to release xdg-open process for URL %q: %v\n", uri, err)
410+ }
411+ }
412+}
413+
395414 // SpawnWithSession starts a new dumber instance to restore a session.
396415 func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
397416 log := logging.FromContext(s.ctx)
@@ -392,6 +392,25 @@ func NewSessionSpawner(ctx context.Context) *SessionSpawner {
392 // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration.392 // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration.
393 const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION"393 const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION"
394 394
395+// LaunchExternalURL opens a URL with the system's default handler (xdg-open on Linux).
396+// Used for external URL schemes like vscode://, spotify://, steam://, etc.
397+func LaunchExternalURL(uri string) {
398+ cmd := exec.Command("xdg-open", uri)
399+ // Detach from current process - we don't care about the result
400+ cmd.Stdin = nil
401+ cmd.Stdout = nil
402+ cmd.Stderr = nil
403+ if err := cmd.Start(); err != nil {
404+ fmt.Fprintf(os.Stderr, "failed to launch external URL %q with xdg-open: %v\n", uri, err)
405+ return
406+ }
407+ if cmd.Process != nil {
408+ if err := cmd.Process.Release(); err != nil {
409+ fmt.Fprintf(os.Stderr, "failed to release xdg-open process for URL %q: %v\n", uri, err)
410+ }
411+ }
412+}
413+
395 // SpawnWithSession starts a new dumber instance to restore a session.414 // SpawnWithSession starts a new dumber instance to restore a session.
396 func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {415 func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
397 log := logging.FromContext(s.ctx)416 log := logging.FromContext(s.ctx)
M internal/infrastructure/webkit/webview.go +143 -38
@@ -10,6 +10,8 @@ import (
1010 "time"
1111
1212 "github.com/bnema/dumber/internal/application/port"
13+ urlutil "github.com/bnema/dumber/internal/domain/url"
14+ "github.com/bnema/dumber/internal/infrastructure/desktop"
1315 "github.com/bnema/dumber/internal/logging"
1416 "github.com/bnema/puregotk-webkit/webkit"
1517 "github.com/jwijenbergh/puregotk/v4/gdk"
@@ -161,6 +163,9 @@ type WebView struct {
161163 // findController is cached to prevent GC from collecting the Go wrapper
162164 findController *findControllerAdapter
163165 findControllerOnce sync.Once
166+
167+ backForwardList *webkit.BackForwardList
168+ backForwardListSignalID uint32
164169 }
165170
166171 type findControllerAdapter struct {
@@ -382,6 +387,7 @@ func (wv *WebView) connectSignals() {
382387 wv.connectLeaveFullscreenSignal()
383388 wv.connectAudioStateSignal()
384389 wv.connectMouseTargetChangedSignal()
390+ wv.connectBackForwardListChangedSignal()
385391 }
386392
387393 func (wv *WebView) connectLoadChangedSignal() {
@@ -392,8 +398,8 @@ func (wv *WebView) connectLoadChangedSignal() {
392398 wv.mu.Lock()
393399 wv.uri = uri
394400 wv.title = title
395- wv.canGoBack = inner.CanGoBack()
396- wv.canGoFwd = inner.CanGoForward()
401+ // Note: canGoBack/canGoFwd are updated via back-forward-list::changed signal
402+ // which fires for both traditional navigation and SPA history.pushState()
397403 wv.progress = inner.GetEstimatedLoadProgress()
398404
399405 switch event {
@@ -557,7 +563,8 @@ func (wv *WebView) connectDecidePolicySignal() {
557563 switch decisionType {
558564 case webkit.PolicyDecisionTypeResponseValue:
559565 return wv.handleResponsePolicyDecision(decisionPtr)
560- case webkit.PolicyDecisionTypeNavigationActionValue:
566+ case webkit.PolicyDecisionTypeNavigationActionValue, webkit.PolicyDecisionTypeNewWindowActionValue:
567+ // Both navigation and new window actions use NavigationPolicyDecision
561568 return wv.handleNavigationPolicyDecision(decisionPtr)
562569 default:
563570 return false
@@ -627,7 +634,7 @@ func (wv *WebView) handleResponsePolicyDecision(decisionPtr uintptr) bool {
627634 return true
628635 }
629636
630-// handleNavigationPolicyDecision handles navigation policy decisions (e.g., middle-click).
637+// handleNavigationPolicyDecision handles navigation policy decisions (e.g., middle-click, external schemes).
631638 func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool {
632639 navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr)
633640 if navDecision == nil {
@@ -639,6 +646,63 @@ func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool {
639646 return false
640647 }
641648
649+ request := navAction.GetRequest()
650+ if request == nil {
651+ return false
652+ }
653+
654+ linkURI := request.GetUri()
655+ if linkURI == "" {
656+ return false
657+ }
658+
659+ // Debug logging to trace navigation decisions
660+ wv.logger.Debug().
661+ Str("uri", linkURI).
662+ Int("nav_type", int(navAction.GetNavigationType())).
663+ Bool("user_gesture", navAction.IsUserGesture()).
664+ Msg("navigation policy decision")
665+
666+ // Check for external URL schemes (e.g., vscode://, vscode-insiders://, spotify://)
667+ // These need to be launched via xdg-open rather than handled by WebKit
668+ // Only launch for user-initiated actions to prevent automatic redirects
669+ // from silently opening external applications
670+ if urlutil.IsExternalScheme(linkURI) {
671+ if !navAction.IsUserGesture() {
672+ // For non-user-initiated redirects, ignore silently
673+ navDecision.Ignore()
674+ return true
675+ }
676+
677+ wv.logger.Info().
678+ Str("uri", linkURI).
679+ Msg("launching external URL scheme via xdg-open")
680+
681+ // Launch asynchronously to avoid blocking WebKit
682+ go desktop.LaunchExternalURL(linkURI)
683+
684+ // Ignore the navigation to prevent WebKit from showing an error
685+ navDecision.Ignore()
686+
687+ // Go back to the previous page to avoid showing WebKit's error page.
688+ // This handles OAuth flows where after successful auth, the callback
689+ // redirects to a custom scheme (vscode://, vscode-insiders://, etc.)
690+ // Schedule on idle to let WebKit process the ignored decision first
691+ cb := glib.SourceFunc(func(_ uintptr) bool {
692+ if wv.inner != nil && !wv.destroyed.Load() && wv.inner.CanGoBack() {
693+ wv.inner.GoBack()
694+ }
695+ return false // Don't repeat
696+ })
697+ wv.mu.Lock()
698+ wv.asyncCallbacks = append(wv.asyncCallbacks, &cb)
699+ wv.mu.Unlock()
700+ glib.IdleAdd(&cb, 0)
701+
702+ return true
703+ }
704+
705+ // Only handle link clicks for middle-click/ctrl-click (open in new tab)
642706 if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue {
643707 return false
644708 }
@@ -652,16 +716,6 @@ func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool {
652716 return false
653717 }
654718
655- request := navAction.GetRequest()
656- if request == nil {
657- return false
658- }
659-
660- linkURI := request.GetUri()
661- if linkURI == "" {
662- return false
663- }
664-
665719 wv.logger.Debug().
666720 Str("uri", linkURI).
667721 Uint("button", mouseButton).
@@ -778,6 +832,34 @@ func (wv *WebView) connectMouseTargetChangedSignal() {
778832 wv.signalIDs = append(wv.signalIDs, sigID)
779833 }
780834
835+func (wv *WebView) connectBackForwardListChangedSignal() {
836+ backForwardList := wv.inner.GetBackForwardList()
837+ if backForwardList == nil {
838+ wv.logger.Warn().Msg("failed to get back-forward-list")
839+ return
840+ }
841+
842+ // The "changed" signal is emitted when the back-forward list changes,
843+ // including for SPA navigation via history.pushState/replaceState.
844+ // Parameters: (item_added, items_removed) - we ignore them and just refresh state.
845+ changedCb := func(_ webkit.BackForwardList, _ uintptr, _ uintptr) {
846+ canBack := wv.inner.CanGoBack()
847+ canFwd := wv.inner.CanGoForward()
848+ wv.logger.Debug().
849+ Bool("can_go_back", canBack).
850+ Bool("can_go_forward", canFwd).
851+ Uint("list_length", backForwardList.GetLength()).
852+ Msg("back-forward-list changed")
853+ wv.mu.Lock()
854+ wv.canGoBack = canBack
855+ wv.canGoFwd = canFwd
856+ wv.mu.Unlock()
857+ }
858+ sigID := backForwardList.ConnectChanged(&changedCb)
859+ wv.backForwardList = backForwardList
860+ wv.backForwardListSignalID = sigID
861+}
862+
781863 // ID returns the unique identifier for this WebView.
782864 func (wv *WebView) ID() WebViewID {
783865 return wv.id
@@ -875,49 +957,65 @@ func (wv *WebView) Stop(ctx context.Context) error {
875957 }
876958
877959 // GoBack navigates back in history.
878-// Uses WebKit native navigation first, falls back to JavaScript history.back() for SPA compatibility.
960+// Uses WebKit's native history navigation.
879961 func (wv *WebView) GoBack(ctx context.Context) error {
880962 if wv.destroyed.Load() {
881963 return fmt.Errorf("webview %d is destroyed", wv.id)
882964 }
883- log := logging.FromContext(ctx)
884965
885- // Try WebKit native navigation first if available (for non-SPA pages)
886- if wv.inner.CanGoBack() {
887- wv.inner.GoBack()
888- log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (native)")
889- return nil
890- }
966+ logging.FromContext(ctx).Debug().
967+ Int("webview_id", int(wv.id)).
968+ Bool("can_go_back", wv.inner.CanGoBack()).
969+ Str("current_uri", wv.inner.GetUri()).
970+ Msg("webview go back")
971+
972+ wv.inner.GoBack()
973+
974+ // Note: We don't dispatch popstate manually anymore.
975+ // WebKit's go_back() should handle SPA navigation correctly.
976+ // If issues persist, the problem is likely elsewhere.
891977
892- // Fall back to JavaScript history.back() for SPA navigation
893- // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track
894- wv.RunJavaScript(ctx, "history.back()", "")
895- log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (js fallback)")
896978 return nil
897979 }
898980
899981 // GoForward navigates forward in history.
900-// Uses WebKit native navigation first, falls back to JavaScript history.forward() for SPA compatibility.
901982 func (wv *WebView) GoForward(ctx context.Context) error {
902983 if wv.destroyed.Load() {
903984 return fmt.Errorf("webview %d is destroyed", wv.id)
904985 }
905- log := logging.FromContext(ctx)
906986
907- // Try WebKit native navigation first if available (for non-SPA pages)
908- if wv.inner.CanGoForward() {
909- wv.inner.GoForward()
910- log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (native)")
911- return nil
912- }
987+ logging.FromContext(ctx).Debug().
988+ Int("webview_id", int(wv.id)).
989+ Bool("can_go_forward", wv.inner.CanGoForward()).
990+ Str("current_uri", wv.inner.GetUri()).
991+ Msg("webview go forward")
992+
993+ wv.inner.GoForward()
913994
914- // Fall back to JavaScript history.forward() for SPA navigation
915- // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track
916- wv.RunJavaScript(ctx, "history.forward()", "")
917- log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (js fallback)")
918995 return nil
919996 }
920997
998+// GoBackDirect calls WebKit's go_back directly without any wrappers.
999+// This is intended for use from gesture handlers where preserving user
1000+// gesture context is critical for SPA popstate handling.
1001+// Matches Epiphany: webkit_web_view_go_back(web_view) directly in gesture callback.
1002+func (wv *WebView) GoBackDirect() {
1003+ if wv.destroyed.Load() {
1004+ return
1005+ }
1006+ wv.inner.GoBack()
1007+}
1008+
1009+// GoForwardDirect calls WebKit's go_forward directly without any wrappers.
1010+// This is intended for use from gesture handlers where preserving user
1011+// gesture context is critical for SPA popstate handling.
1012+func (wv *WebView) GoForwardDirect() {
1013+ if wv.destroyed.Load() {
1014+ return
1015+ }
1016+ wv.inner.GoForward()
1017+}
1018+
9211019 // CanGoBack returns true if back navigation is possible.
9221020 func (wv *WebView) CanGoBack() bool {
9231021 wv.mu.RLock()
@@ -1142,6 +1240,13 @@ func (wv *WebView) DisconnectSignals() {
11421240 }
11431241 wv.signalIDs = wv.signalIDs[:0] // Clear the slice
11441242
1243+ if wv.backForwardList != nil && wv.backForwardListSignalID != 0 {
1244+ bfObj := gobject.ObjectNewFromInternalPtr(wv.backForwardList.GoPointer())
1245+ gobject.SignalHandlerDisconnect(bfObj, wv.backForwardListSignalID)
1246+ wv.backForwardListSignalID = 0
1247+ wv.backForwardList = nil
1248+ }
1249+
11451250 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("signals disconnected")
11461251 }
11471252
@@ -10,6 +10,8 @@ import (
10 "time"10 "time"
11 11
12 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
13+ urlutil "github.com/bnema/dumber/internal/domain/url"
14+ "github.com/bnema/dumber/internal/infrastructure/desktop"
13 "github.com/bnema/dumber/internal/logging"15 "github.com/bnema/dumber/internal/logging"
14 "github.com/bnema/puregotk-webkit/webkit"16 "github.com/bnema/puregotk-webkit/webkit"
15 "github.com/jwijenbergh/puregotk/v4/gdk"17 "github.com/jwijenbergh/puregotk/v4/gdk"
@@ -161,6 +163,9 @@ type WebView struct {
161 // findController is cached to prevent GC from collecting the Go wrapper163 // findController is cached to prevent GC from collecting the Go wrapper
162 findController *findControllerAdapter164 findController *findControllerAdapter
163 findControllerOnce sync.Once165 findControllerOnce sync.Once
166+
167+ backForwardList *webkit.BackForwardList
168+ backForwardListSignalID uint32
164 }169 }
165 170
166 type findControllerAdapter struct {171 type findControllerAdapter struct {
@@ -382,6 +387,7 @@ func (wv *WebView) connectSignals() {
382 wv.connectLeaveFullscreenSignal()387 wv.connectLeaveFullscreenSignal()
383 wv.connectAudioStateSignal()388 wv.connectAudioStateSignal()
384 wv.connectMouseTargetChangedSignal()389 wv.connectMouseTargetChangedSignal()
390+ wv.connectBackForwardListChangedSignal()
385 }391 }
386 392
387 func (wv *WebView) connectLoadChangedSignal() {393 func (wv *WebView) connectLoadChangedSignal() {
@@ -392,8 +398,8 @@ func (wv *WebView) connectLoadChangedSignal() {
392 wv.mu.Lock()398 wv.mu.Lock()
393 wv.uri = uri399 wv.uri = uri
394 wv.title = title400 wv.title = title
395- wv.canGoBack = inner.CanGoBack()401+ // Note: canGoBack/canGoFwd are updated via back-forward-list::changed signal
396- wv.canGoFwd = inner.CanGoForward()402+ // which fires for both traditional navigation and SPA history.pushState()
397 wv.progress = inner.GetEstimatedLoadProgress()403 wv.progress = inner.GetEstimatedLoadProgress()
398 404
399 switch event {405 switch event {
@@ -557,7 +563,8 @@ func (wv *WebView) connectDecidePolicySignal() {
557 switch decisionType {563 switch decisionType {
558 case webkit.PolicyDecisionTypeResponseValue:564 case webkit.PolicyDecisionTypeResponseValue:
559 return wv.handleResponsePolicyDecision(decisionPtr)565 return wv.handleResponsePolicyDecision(decisionPtr)
560- case webkit.PolicyDecisionTypeNavigationActionValue:566+ case webkit.PolicyDecisionTypeNavigationActionValue, webkit.PolicyDecisionTypeNewWindowActionValue:
567+ // Both navigation and new window actions use NavigationPolicyDecision
561 return wv.handleNavigationPolicyDecision(decisionPtr)568 return wv.handleNavigationPolicyDecision(decisionPtr)
562 default:569 default:
563 return false570 return false
@@ -627,7 +634,7 @@ func (wv *WebView) handleResponsePolicyDecision(decisionPtr uintptr) bool {
627 return true634 return true
628 }635 }
629 636
630-// handleNavigationPolicyDecision handles navigation policy decisions (e.g., middle-click).637+// handleNavigationPolicyDecision handles navigation policy decisions (e.g., middle-click, external schemes).
631 func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool {638 func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool {
632 navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr)639 navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr)
633 if navDecision == nil {640 if navDecision == nil {
@@ -639,6 +646,63 @@ func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool {
639 return false646 return false
640 }647 }
641 648
649+ request := navAction.GetRequest()
650+ if request == nil {
651+ return false
652+ }
653+
654+ linkURI := request.GetUri()
655+ if linkURI == "" {
656+ return false
657+ }
658+
659+ // Debug logging to trace navigation decisions
660+ wv.logger.Debug().
661+ Str("uri", linkURI).
662+ Int("nav_type", int(navAction.GetNavigationType())).
663+ Bool("user_gesture", navAction.IsUserGesture()).
664+ Msg("navigation policy decision")
665+
666+ // Check for external URL schemes (e.g., vscode://, vscode-insiders://, spotify://)
667+ // These need to be launched via xdg-open rather than handled by WebKit
668+ // Only launch for user-initiated actions to prevent automatic redirects
669+ // from silently opening external applications
670+ if urlutil.IsExternalScheme(linkURI) {
671+ if !navAction.IsUserGesture() {
672+ // For non-user-initiated redirects, ignore silently
673+ navDecision.Ignore()
674+ return true
675+ }
676+
677+ wv.logger.Info().
678+ Str("uri", linkURI).
679+ Msg("launching external URL scheme via xdg-open")
680+
681+ // Launch asynchronously to avoid blocking WebKit
682+ go desktop.LaunchExternalURL(linkURI)
683+
684+ // Ignore the navigation to prevent WebKit from showing an error
685+ navDecision.Ignore()
686+
687+ // Go back to the previous page to avoid showing WebKit's error page.
688+ // This handles OAuth flows where after successful auth, the callback
689+ // redirects to a custom scheme (vscode://, vscode-insiders://, etc.)
690+ // Schedule on idle to let WebKit process the ignored decision first
691+ cb := glib.SourceFunc(func(_ uintptr) bool {
692+ if wv.inner != nil && !wv.destroyed.Load() && wv.inner.CanGoBack() {
693+ wv.inner.GoBack()
694+ }
695+ return false // Don't repeat
696+ })
697+ wv.mu.Lock()
698+ wv.asyncCallbacks = append(wv.asyncCallbacks, &cb)
699+ wv.mu.Unlock()
700+ glib.IdleAdd(&cb, 0)
701+
702+ return true
703+ }
704+
705+ // Only handle link clicks for middle-click/ctrl-click (open in new tab)
642 if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue {706 if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue {
643 return false707 return false
644 }708 }
@@ -652,16 +716,6 @@ func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool {
652 return false716 return false
653 }717 }
654 718
655- request := navAction.GetRequest()
656- if request == nil {
657- return false
658- }
659-
660- linkURI := request.GetUri()
661- if linkURI == "" {
662- return false
663- }
664-
665 wv.logger.Debug().719 wv.logger.Debug().
666 Str("uri", linkURI).720 Str("uri", linkURI).
667 Uint("button", mouseButton).721 Uint("button", mouseButton).
@@ -778,6 +832,34 @@ func (wv *WebView) connectMouseTargetChangedSignal() {
778 wv.signalIDs = append(wv.signalIDs, sigID)832 wv.signalIDs = append(wv.signalIDs, sigID)
779 }833 }
780 834
835+func (wv *WebView) connectBackForwardListChangedSignal() {
836+ backForwardList := wv.inner.GetBackForwardList()
837+ if backForwardList == nil {
838+ wv.logger.Warn().Msg("failed to get back-forward-list")
839+ return
840+ }
841+
842+ // The "changed" signal is emitted when the back-forward list changes,
843+ // including for SPA navigation via history.pushState/replaceState.
844+ // Parameters: (item_added, items_removed) - we ignore them and just refresh state.
845+ changedCb := func(_ webkit.BackForwardList, _ uintptr, _ uintptr) {
846+ canBack := wv.inner.CanGoBack()
847+ canFwd := wv.inner.CanGoForward()
848+ wv.logger.Debug().
849+ Bool("can_go_back", canBack).
850+ Bool("can_go_forward", canFwd).
851+ Uint("list_length", backForwardList.GetLength()).
852+ Msg("back-forward-list changed")
853+ wv.mu.Lock()
854+ wv.canGoBack = canBack
855+ wv.canGoFwd = canFwd
856+ wv.mu.Unlock()
857+ }
858+ sigID := backForwardList.ConnectChanged(&changedCb)
859+ wv.backForwardList = backForwardList
860+ wv.backForwardListSignalID = sigID
861+}
862+
781 // ID returns the unique identifier for this WebView.863 // ID returns the unique identifier for this WebView.
782 func (wv *WebView) ID() WebViewID {864 func (wv *WebView) ID() WebViewID {
783 return wv.id865 return wv.id
@@ -875,49 +957,65 @@ func (wv *WebView) Stop(ctx context.Context) error {
875 }957 }
876 958
877 // GoBack navigates back in history.959 // GoBack navigates back in history.
878-// Uses WebKit native navigation first, falls back to JavaScript history.back() for SPA compatibility.960+// Uses WebKit's native history navigation.
879 func (wv *WebView) GoBack(ctx context.Context) error {961 func (wv *WebView) GoBack(ctx context.Context) error {
880 if wv.destroyed.Load() {962 if wv.destroyed.Load() {
881 return fmt.Errorf("webview %d is destroyed", wv.id)963 return fmt.Errorf("webview %d is destroyed", wv.id)
882 }964 }
883- log := logging.FromContext(ctx)
884 965
885- // Try WebKit native navigation first if available (for non-SPA pages)966+ logging.FromContext(ctx).Debug().
886- if wv.inner.CanGoBack() {967+ Int("webview_id", int(wv.id)).
887- wv.inner.GoBack()968+ Bool("can_go_back", wv.inner.CanGoBack()).
888- log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (native)")969+ Str("current_uri", wv.inner.GetUri()).
889- return nil970+ Msg("webview go back")
890- }971+
972+ wv.inner.GoBack()
973+
974+ // Note: We don't dispatch popstate manually anymore.
975+ // WebKit's go_back() should handle SPA navigation correctly.
976+ // If issues persist, the problem is likely elsewhere.
891 977
892- // Fall back to JavaScript history.back() for SPA navigation
893- // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track
894- wv.RunJavaScript(ctx, "history.back()", "")
895- log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (js fallback)")
896 return nil978 return nil
897 }979 }
898 980
899 // GoForward navigates forward in history.981 // GoForward navigates forward in history.
900-// Uses WebKit native navigation first, falls back to JavaScript history.forward() for SPA compatibility.
901 func (wv *WebView) GoForward(ctx context.Context) error {982 func (wv *WebView) GoForward(ctx context.Context) error {
902 if wv.destroyed.Load() {983 if wv.destroyed.Load() {
903 return fmt.Errorf("webview %d is destroyed", wv.id)984 return fmt.Errorf("webview %d is destroyed", wv.id)
904 }985 }
905- log := logging.FromContext(ctx)
906 986
907- // Try WebKit native navigation first if available (for non-SPA pages)987+ logging.FromContext(ctx).Debug().
908- if wv.inner.CanGoForward() {988+ Int("webview_id", int(wv.id)).
909- wv.inner.GoForward()989+ Bool("can_go_forward", wv.inner.CanGoForward()).
910- log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (native)")990+ Str("current_uri", wv.inner.GetUri()).
911- return nil991+ Msg("webview go forward")
912- }992+
993+ wv.inner.GoForward()
913 994
914- // Fall back to JavaScript history.forward() for SPA navigation
915- // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track
916- wv.RunJavaScript(ctx, "history.forward()", "")
917- log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (js fallback)")
918 return nil995 return nil
919 }996 }
920 997
998+// GoBackDirect calls WebKit's go_back directly without any wrappers.
999+// This is intended for use from gesture handlers where preserving user
1000+// gesture context is critical for SPA popstate handling.
1001+// Matches Epiphany: webkit_web_view_go_back(web_view) directly in gesture callback.
1002+func (wv *WebView) GoBackDirect() {
1003+ if wv.destroyed.Load() {
1004+ return
1005+ }
1006+ wv.inner.GoBack()
1007+}
1008+
1009+// GoForwardDirect calls WebKit's go_forward directly without any wrappers.
1010+// This is intended for use from gesture handlers where preserving user
1011+// gesture context is critical for SPA popstate handling.
1012+func (wv *WebView) GoForwardDirect() {
1013+ if wv.destroyed.Load() {
1014+ return
1015+ }
1016+ wv.inner.GoForward()
1017+}
1018+
921 // CanGoBack returns true if back navigation is possible.1019 // CanGoBack returns true if back navigation is possible.
922 func (wv *WebView) CanGoBack() bool {1020 func (wv *WebView) CanGoBack() bool {
923 wv.mu.RLock()1021 wv.mu.RLock()
@@ -1142,6 +1240,13 @@ func (wv *WebView) DisconnectSignals() {
1142 }1240 }
1143 wv.signalIDs = wv.signalIDs[:0] // Clear the slice1241 wv.signalIDs = wv.signalIDs[:0] // Clear the slice
1144 1242
1243+ if wv.backForwardList != nil && wv.backForwardListSignalID != 0 {
1244+ bfObj := gobject.ObjectNewFromInternalPtr(wv.backForwardList.GoPointer())
1245+ gobject.SignalHandlerDisconnect(bfObj, wv.backForwardListSignalID)
1246+ wv.backForwardListSignalID = 0
1247+ wv.backForwardList = nil
1248+ }
1249+
1145 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("signals disconnected")1250 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("signals disconnected")
1146 }1251 }
1147 1252
M internal/ui/coordinator/content.go +44 -5
@@ -11,7 +11,9 @@ import (
1111 "github.com/bnema/dumber/internal/application/port"
1212 "github.com/bnema/dumber/internal/application/usecase"
1313 "github.com/bnema/dumber/internal/domain/entity"
14+ urlutil "github.com/bnema/dumber/internal/domain/url"
1415 "github.com/bnema/dumber/internal/infrastructure/config"
16+ "github.com/bnema/dumber/internal/infrastructure/desktop"
1517 "github.com/bnema/dumber/internal/infrastructure/webkit"
1618 "github.com/bnema/dumber/internal/logging"
1719 "github.com/bnema/dumber/internal/ui/adapter"
@@ -327,11 +329,16 @@ func (c *ContentCoordinator) WrapWidget(ctx context.Context, wv *webkit.WebView)
327329 widget := c.widgetFactory.WrapWidget(&gtkView.Widget)
328330
329331 // Attach gesture handler for mouse button 8/9 navigation
330- if widget != nil && c.gestureActionHandler != nil {
332+ if widget != nil {
331333 gestureHandler := input.NewGestureHandler(ctx)
332- gestureHandler.SetOnAction(c.gestureActionHandler)
334+ // Pass WebView directly to preserve user gesture context (like Epiphany)
335+ gestureHandler.SetNavigator(wv)
336+ // Keep callback as fallback
337+ if c.gestureActionHandler != nil {
338+ gestureHandler.SetOnAction(c.gestureActionHandler)
339+ }
333340 gestureHandler.AttachTo(widget.GtkWidget())
334- log.Debug().Msg("gesture handler attached to webview")
341+ log.Debug().Msg("gesture handler attached to webview with direct navigator")
335342 }
336343
337344 return widget
@@ -1444,9 +1451,41 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
14441451 c.onProgressChanged(paneID, progress)
14451452 }
14461453
1447- // SPA navigation
1454+ // SPA navigation and external scheme handling
14481455 wv.OnURIChanged = func(uri string) {
1449- if !wv.IsLoading() && uri != "" {
1456+ log := logging.FromContext(ctx)
1457+ if uri == "" {
1458+ return
1459+ }
1460+
1461+ // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)
1462+ // These are typically triggered by JavaScript redirects (window.location)
1463+ isExternal := urlutil.IsExternalScheme(uri)
1464+ log.Debug().
1465+ Str("pane_id", string(paneID)).
1466+ Str("uri", uri).
1467+ Bool("is_external", isExternal).
1468+ Bool("is_loading", wv.IsLoading()).
1469+ Msg("OnURIChanged")
1470+
1471+ if isExternal {
1472+ log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")
1473+
1474+ // Launch externally
1475+ desktop.LaunchExternalURL(uri)
1476+
1477+ // Stop loading to prevent WebKit from showing an error page
1478+ // The page stays on the previous URL before the JS redirect
1479+ _ = wv.Stop(ctx)
1480+
1481+ // Navigate back to avoid stale URI in omnibox/history
1482+ if wv.CanGoBack() {
1483+ _ = wv.GoBack(ctx)
1484+ }
1485+ return
1486+ }
1487+
1488+ if !wv.IsLoading() {
14501489 c.onSPANavigation(ctx, paneID, uri)
14511490 }
14521491 }
@@ -11,7 +11,9 @@ import (
11 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/application/usecase"12 "github.com/bnema/dumber/internal/application/usecase"
13 "github.com/bnema/dumber/internal/domain/entity"13 "github.com/bnema/dumber/internal/domain/entity"
14+ urlutil "github.com/bnema/dumber/internal/domain/url"
14 "github.com/bnema/dumber/internal/infrastructure/config"15 "github.com/bnema/dumber/internal/infrastructure/config"
16+ "github.com/bnema/dumber/internal/infrastructure/desktop"
15 "github.com/bnema/dumber/internal/infrastructure/webkit"17 "github.com/bnema/dumber/internal/infrastructure/webkit"
16 "github.com/bnema/dumber/internal/logging"18 "github.com/bnema/dumber/internal/logging"
17 "github.com/bnema/dumber/internal/ui/adapter"19 "github.com/bnema/dumber/internal/ui/adapter"
@@ -327,11 +329,16 @@ func (c *ContentCoordinator) WrapWidget(ctx context.Context, wv *webkit.WebView)
327 widget := c.widgetFactory.WrapWidget(&gtkView.Widget)329 widget := c.widgetFactory.WrapWidget(&gtkView.Widget)
328 330
329 // Attach gesture handler for mouse button 8/9 navigation331 // Attach gesture handler for mouse button 8/9 navigation
330- if widget != nil && c.gestureActionHandler != nil {332+ if widget != nil {
331 gestureHandler := input.NewGestureHandler(ctx)333 gestureHandler := input.NewGestureHandler(ctx)
332- gestureHandler.SetOnAction(c.gestureActionHandler)334+ // Pass WebView directly to preserve user gesture context (like Epiphany)
335+ gestureHandler.SetNavigator(wv)
336+ // Keep callback as fallback
337+ if c.gestureActionHandler != nil {
338+ gestureHandler.SetOnAction(c.gestureActionHandler)
339+ }
333 gestureHandler.AttachTo(widget.GtkWidget())340 gestureHandler.AttachTo(widget.GtkWidget())
334- log.Debug().Msg("gesture handler attached to webview")341+ log.Debug().Msg("gesture handler attached to webview with direct navigator")
335 }342 }
336 343
337 return widget344 return widget
@@ -1444,9 +1451,41 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
1444 c.onProgressChanged(paneID, progress)1451 c.onProgressChanged(paneID, progress)
1445 }1452 }
1446 1453
1447- // SPA navigation1454+ // SPA navigation and external scheme handling
1448 wv.OnURIChanged = func(uri string) {1455 wv.OnURIChanged = func(uri string) {
1449- if !wv.IsLoading() && uri != "" {1456+ log := logging.FromContext(ctx)
1457+ if uri == "" {
1458+ return
1459+ }
1460+
1461+ // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)
1462+ // These are typically triggered by JavaScript redirects (window.location)
1463+ isExternal := urlutil.IsExternalScheme(uri)
1464+ log.Debug().
1465+ Str("pane_id", string(paneID)).
1466+ Str("uri", uri).
1467+ Bool("is_external", isExternal).
1468+ Bool("is_loading", wv.IsLoading()).
1469+ Msg("OnURIChanged")
1470+
1471+ if isExternal {
1472+ log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")
1473+
1474+ // Launch externally
1475+ desktop.LaunchExternalURL(uri)
1476+
1477+ // Stop loading to prevent WebKit from showing an error page
1478+ // The page stays on the previous URL before the JS redirect
1479+ _ = wv.Stop(ctx)
1480+
1481+ // Navigate back to avoid stale URI in omnibox/history
1482+ if wv.CanGoBack() {
1483+ _ = wv.GoBack(ctx)
1484+ }
1485+ return
1486+ }
1487+
1488+ if !wv.IsLoading() {
1450 c.onSPANavigation(ctx, paneID, uri)1489 c.onSPANavigation(ctx, paneID, uri)
1451 }1490 }
1452 }1491 }
M internal/ui/input/gesture.go +62 -27
@@ -8,6 +8,18 @@ import (
88 "github.com/jwijenbergh/puregotk/v4/gtk"
99 )
1010
11+// DirectNavigator allows direct back/forward navigation on a WebView.
12+// This is used to call GoBack/GoForward directly from gesture handlers
13+// without going through callback chains, preserving user gesture context.
14+type DirectNavigator interface {
15+ GoBackDirect()
16+ GoForwardDirect()
17+}
18+
19+// gtkEventSequenceClaimed matches GTK_EVENT_SEQUENCE_CLAIMED (value 1)
20+// Used to tell GTK we've handled the gesture and to stop propagating it.
21+const gtkEventSequenceClaimed = gtk.EventSequenceClaimedValue
22+
1123 const (
1224 // Mouse button constants for X11/Wayland
1325 mouseButtonBack = 8 // Side button - back
@@ -22,9 +34,13 @@ type GestureHandler struct {
2234 // Callback retention: must stay reachable by Go GC.
2335 pressedCb func(gtk.GestureClick, int, float64, float64)
2436
25- // Action handler callback
37+ // Action handler callback (fallback if no direct navigator)
2638 onAction ActionHandler
2739
40+ // Direct navigator for calling GoBack/GoForward without callback chains.
41+ // This preserves user gesture context like Epiphany does.
42+ navigator DirectNavigator
43+
2844 ctx context.Context
2945 mu sync.RWMutex
3046 }
@@ -40,12 +56,22 @@ func NewGestureHandler(ctx context.Context) *GestureHandler {
4056 }
4157
4258 // SetOnAction sets the callback for when navigation actions are triggered.
59+// This is a fallback if no DirectNavigator is set.
4360 func (h *GestureHandler) SetOnAction(fn ActionHandler) {
4461 h.mu.Lock()
4562 defer h.mu.Unlock()
4663 h.onAction = fn
4764 }
4865
66+// SetNavigator sets a direct navigator for back/forward operations.
67+// When set, GoBack/GoForward are called directly without callback chains,
68+// preserving user gesture context.
69+func (h *GestureHandler) SetNavigator(nav DirectNavigator) {
70+ h.mu.Lock()
71+ defer h.mu.Unlock()
72+ h.navigator = nav
73+}
74+
4975 // AttachTo attaches the gesture handler to a GTK widget.
5076 // Typically called with a WebView widget to enable mouse button navigation.
5177 func (h *GestureHandler) AttachTo(widget *gtk.Widget) {
@@ -88,40 +114,49 @@ func (h *GestureHandler) handlePressed(nPress int) {
88114 // Get the button that was pressed
89115 button := h.clickGesture.GetCurrentButton()
90116
91- var action Action
117+ log := logging.FromContext(h.ctx)
118+
119+ // Try direct navigation first (preserves user gesture context)
120+ h.mu.RLock()
121+ nav := h.navigator
122+ handler := h.onAction
123+ h.mu.RUnlock()
124+
125+ handled := false
126+
92127 switch button {
93128 case mouseButtonBack:
94- action = ActionGoBack
129+ if nav != nil {
130+ log.Debug().Uint("button", button).Msg("gesture: direct go back")
131+ nav.GoBackDirect()
132+ handled = true
133+ } else if handler != nil {
134+ if err := handler(h.ctx, ActionGoBack); err != nil {
135+ log.Warn().Err(err).Msg("gesture action failed")
136+ }
137+ handled = true
138+ }
95139 case mouseButtonForward:
96- action = ActionGoForward
140+ if nav != nil {
141+ log.Debug().Uint("button", button).Msg("gesture: direct go forward")
142+ nav.GoForwardDirect()
143+ handled = true
144+ } else if handler != nil {
145+ if err := handler(h.ctx, ActionGoForward); err != nil {
146+ log.Warn().Err(err).Msg("gesture action failed")
147+ }
148+ handled = true
149+ }
97150 default:
98151 // Not a navigation button, ignore
99152 return
100153 }
101154
102- // Dispatch action to handler
103- h.mu.RLock()
104- handler := h.onAction
105- h.mu.RUnlock()
106-
107- if handler != nil {
108- if err := handler(h.ctx, action); err != nil {
109- log := logging.FromContext(h.ctx)
110- // Navigation errors are expected (e.g., no back/forward history)
111- if action == ActionGoBack || action == ActionGoForward {
112- log.Debug().
113- Err(err).
114- Str("action", string(action)).
115- Uint("button", button).
116- Msg("gesture navigation not available")
117- } else {
118- log.Error().
119- Err(err).
120- Str("action", string(action)).
121- Uint("button", button).
122- Msg("gesture action handler error")
123- }
124- }
155+ // Claim the gesture sequence to stop event propagation.
156+ // This matches Epiphany's behavior: gtk_gesture_set_state(gesture, GTK_EVENT_SEQUENCE_CLAIMED)
157+ // Without this, GTK may continue propagating the event which can interfere with WebKit.
158+ if handled {
159+ h.clickGesture.SetState(gtkEventSequenceClaimed)
125160 }
126161 }
127162
@@ -8,6 +8,18 @@ import (
8 "github.com/jwijenbergh/puregotk/v4/gtk"8 "github.com/jwijenbergh/puregotk/v4/gtk"
9 )9 )
10 10
11+// DirectNavigator allows direct back/forward navigation on a WebView.
12+// This is used to call GoBack/GoForward directly from gesture handlers
13+// without going through callback chains, preserving user gesture context.
14+type DirectNavigator interface {
15+ GoBackDirect()
16+ GoForwardDirect()
17+}
18+
19+// gtkEventSequenceClaimed matches GTK_EVENT_SEQUENCE_CLAIMED (value 1)
20+// Used to tell GTK we've handled the gesture and to stop propagating it.
21+const gtkEventSequenceClaimed = gtk.EventSequenceClaimedValue
22+
11 const (23 const (
12 // Mouse button constants for X11/Wayland24 // Mouse button constants for X11/Wayland
13 mouseButtonBack = 8 // Side button - back25 mouseButtonBack = 8 // Side button - back
@@ -22,9 +34,13 @@ type GestureHandler struct {
22 // Callback retention: must stay reachable by Go GC.34 // Callback retention: must stay reachable by Go GC.
23 pressedCb func(gtk.GestureClick, int, float64, float64)35 pressedCb func(gtk.GestureClick, int, float64, float64)
24 36
25- // Action handler callback37+ // Action handler callback (fallback if no direct navigator)
26 onAction ActionHandler38 onAction ActionHandler
27 39
40+ // Direct navigator for calling GoBack/GoForward without callback chains.
41+ // This preserves user gesture context like Epiphany does.
42+ navigator DirectNavigator
43+
28 ctx context.Context44 ctx context.Context
29 mu sync.RWMutex45 mu sync.RWMutex
30 }46 }
@@ -40,12 +56,22 @@ func NewGestureHandler(ctx context.Context) *GestureHandler {
40 }56 }
41 57
42 // SetOnAction sets the callback for when navigation actions are triggered.58 // SetOnAction sets the callback for when navigation actions are triggered.
59+// This is a fallback if no DirectNavigator is set.
43 func (h *GestureHandler) SetOnAction(fn ActionHandler) {60 func (h *GestureHandler) SetOnAction(fn ActionHandler) {
44 h.mu.Lock()61 h.mu.Lock()
45 defer h.mu.Unlock()62 defer h.mu.Unlock()
46 h.onAction = fn63 h.onAction = fn
47 }64 }
48 65
66+// SetNavigator sets a direct navigator for back/forward operations.
67+// When set, GoBack/GoForward are called directly without callback chains,
68+// preserving user gesture context.
69+func (h *GestureHandler) SetNavigator(nav DirectNavigator) {
70+ h.mu.Lock()
71+ defer h.mu.Unlock()
72+ h.navigator = nav
73+}
74+
49 // AttachTo attaches the gesture handler to a GTK widget.75 // AttachTo attaches the gesture handler to a GTK widget.
50 // Typically called with a WebView widget to enable mouse button navigation.76 // Typically called with a WebView widget to enable mouse button navigation.
51 func (h *GestureHandler) AttachTo(widget *gtk.Widget) {77 func (h *GestureHandler) AttachTo(widget *gtk.Widget) {
@@ -88,40 +114,49 @@ func (h *GestureHandler) handlePressed(nPress int) {
88 // Get the button that was pressed114 // Get the button that was pressed
89 button := h.clickGesture.GetCurrentButton()115 button := h.clickGesture.GetCurrentButton()
90 116
91- var action Action117+ log := logging.FromContext(h.ctx)
118+
119+ // Try direct navigation first (preserves user gesture context)
120+ h.mu.RLock()
121+ nav := h.navigator
122+ handler := h.onAction
123+ h.mu.RUnlock()
124+
125+ handled := false
126+
92 switch button {127 switch button {
93 case mouseButtonBack:128 case mouseButtonBack:
94- action = ActionGoBack129+ if nav != nil {
130+ log.Debug().Uint("button", button).Msg("gesture: direct go back")
131+ nav.GoBackDirect()
132+ handled = true
133+ } else if handler != nil {
134+ if err := handler(h.ctx, ActionGoBack); err != nil {
135+ log.Warn().Err(err).Msg("gesture action failed")
136+ }
137+ handled = true
138+ }
95 case mouseButtonForward:139 case mouseButtonForward:
96- action = ActionGoForward140+ if nav != nil {
141+ log.Debug().Uint("button", button).Msg("gesture: direct go forward")
142+ nav.GoForwardDirect()
143+ handled = true
144+ } else if handler != nil {
145+ if err := handler(h.ctx, ActionGoForward); err != nil {
146+ log.Warn().Err(err).Msg("gesture action failed")
147+ }
148+ handled = true
149+ }
97 default:150 default:
98 // Not a navigation button, ignore151 // Not a navigation button, ignore
99 return152 return
100 }153 }
101 154
102- // Dispatch action to handler155+ // Claim the gesture sequence to stop event propagation.
103- h.mu.RLock()156+ // This matches Epiphany's behavior: gtk_gesture_set_state(gesture, GTK_EVENT_SEQUENCE_CLAIMED)
104- handler := h.onAction157+ // Without this, GTK may continue propagating the event which can interfere with WebKit.
105- h.mu.RUnlock()158+ if handled {
106-159+ h.clickGesture.SetState(gtkEventSequenceClaimed)
107- if handler != nil {
108- if err := handler(h.ctx, action); err != nil {
109- log := logging.FromContext(h.ctx)
110- // Navigation errors are expected (e.g., no back/forward history)
111- if action == ActionGoBack || action == ActionGoForward {
112- log.Debug().
113- Err(err).
114- Str("action", string(action)).
115- Uint("button", button).
116- Msg("gesture navigation not available")
117- } else {
118- log.Error().
119- Err(err).
120- Str("action", string(action)).
121- Uint("button", button).
122- Msg("gesture action handler error")
123- }
124- }
125 }160 }
126 }161 }
127 162

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