detached 41f8d61

Merge pull request #115 from bnema/fix/spa-mouse-navigation

fix: SPA mouse back/forward navigation using JS history fallback
commit
41f8d6183c4ab723b0f5191688e33386732c237d
author
bnema · · 2026-01-08 17:27 UTC
committer
GitHub · · 2026-01-08 17:27 UTC
merge of
0ae133e + 928cfae · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
5 files · +31 -44
M CHANGELOG.md +1 -0
@@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file.
1313 - **Navigation shortcuts**: `Ctrl+Left/Right` now triggers back/forward navigation, `Alt+Left/Right` moves focus between panes. Fixes #98.
1414
1515 ### Fixed
16+- **SPA mouse navigation**: Fixed unreliable back/forward mouse button navigation in Single Page Applications. WebKit's `BackForwardList` doesn't track JavaScript History API (`pushState`/`replaceState`) navigation. Now uses JavaScript `history.back()`/`history.forward()` as fallback when WebKit native navigation is unavailable.
1617 - **Accent picker freeze**: Fixed UI freeze when selecting an accent by pressing Enter or Escape. The deadlock was caused by the callback (which calls `Hide()`) being invoked while holding the mutex that `Hide()` also needs to acquire.
1718 - **Stacked pane title mismatch**: Fixed title bar showing wrong page title after closing panes in a stack. Refactored stacked pane management to use proper use cases, ensuring domain model stays in sync with UI.
1819 - **Flatpak favicons**: Fixed favicons not displaying in omnibox for Flatpak installs by ensuring cache directory exists before saving. Fixes #105.
@@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file.
13 - **Navigation shortcuts**: `Ctrl+Left/Right` now triggers back/forward navigation, `Alt+Left/Right` moves focus between panes. Fixes #98.13 - **Navigation shortcuts**: `Ctrl+Left/Right` now triggers back/forward navigation, `Alt+Left/Right` moves focus between panes. Fixes #98.
14 14
15 ### Fixed15 ### Fixed
16+- **SPA mouse navigation**: Fixed unreliable back/forward mouse button navigation in Single Page Applications. WebKit's `BackForwardList` doesn't track JavaScript History API (`pushState`/`replaceState`) navigation. Now uses JavaScript `history.back()`/`history.forward()` as fallback when WebKit native navigation is unavailable.
16 - **Accent picker freeze**: Fixed UI freeze when selecting an accent by pressing Enter or Escape. The deadlock was caused by the callback (which calls `Hide()`) being invoked while holding the mutex that `Hide()` also needs to acquire.17 - **Accent picker freeze**: Fixed UI freeze when selecting an accent by pressing Enter or Escape. The deadlock was caused by the callback (which calls `Hide()`) being invoked while holding the mutex that `Hide()` also needs to acquire.
17 - **Stacked pane title mismatch**: Fixed title bar showing wrong page title after closing panes in a stack. Refactored stacked pane management to use proper use cases, ensuring domain model stays in sync with UI.18 - **Stacked pane title mismatch**: Fixed title bar showing wrong page title after closing panes in a stack. Refactored stacked pane management to use proper use cases, ensuring domain model stays in sync with UI.
18 - **Flatpak favicons**: Fixed favicons not displaying in omnibox for Flatpak installs by ensuring cache directory exists before saving. Fixes #105.19 - **Flatpak favicons**: Fixed favicons not displaying in omnibox for Flatpak installs by ensuring cache directory exists before saving. Fixes #105.
M internal/application/port/webview.go +2 -2
@@ -143,11 +143,11 @@ type WebView interface {
143143 Stop(ctx context.Context) error
144144
145145 // GoBack navigates back in history.
146- // Returns error if back navigation is not possible.
146+ // Uses WebKit native navigation if available, falls back to JavaScript history.back() for SPA compatibility.
147147 GoBack(ctx context.Context) error
148148
149149 // GoForward navigates forward in history.
150- // Returns error if forward navigation is not possible.
150+ // Uses WebKit native navigation if available, falls back to JavaScript history.forward() for SPA compatibility.
151151 GoForward(ctx context.Context) error
152152
153153 // --- State Queries ---
@@ -143,11 +143,11 @@ type WebView interface {
143 Stop(ctx context.Context) error143 Stop(ctx context.Context) error
144 144
145 // GoBack navigates back in history.145 // GoBack navigates back in history.
146- // Returns error if back navigation is not possible.146+ // Uses WebKit native navigation if available, falls back to JavaScript history.back() for SPA compatibility.
147 GoBack(ctx context.Context) error147 GoBack(ctx context.Context) error
148 148
149 // GoForward navigates forward in history.149 // GoForward navigates forward in history.
150- // Returns error if forward navigation is not possible.150+ // Uses WebKit native navigation if available, falls back to JavaScript history.forward() for SPA compatibility.
151 GoForward(ctx context.Context) error151 GoForward(ctx context.Context) error
152 152
153 // --- State Queries ---153 // --- State Queries ---
M internal/application/usecase/navigate.go +0 -26
@@ -240,32 +240,6 @@ func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, byp
240240 return webview.Reload(ctx)
241241 }
242242
243-// GoBack navigates back in history.
244-func (uc *NavigateUseCase) GoBack(ctx context.Context, webview port.WebView) error {
245- log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
246-
247- if !webview.CanGoBack() {
248- log.Debug().Msg("cannot go back - no history")
249- return nil
250- }
251-
252- log.Debug().Msg("navigating back")
253- return webview.GoBack(ctx)
254-}
255-
256-// GoForward navigates forward in history.
257-func (uc *NavigateUseCase) GoForward(ctx context.Context, webview port.WebView) error {
258- log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
259-
260- if !webview.CanGoForward() {
261- log.Debug().Msg("cannot go forward - no history")
262- return nil
263- }
264-
265- log.Debug().Msg("navigating forward")
266- return webview.GoForward(ctx)
267-}
268-
269243 // Stop stops the current page load.
270244 func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error {
271245 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
@@ -240,32 +240,6 @@ func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, byp
240 return webview.Reload(ctx)240 return webview.Reload(ctx)
241 }241 }
242 242
243-// GoBack navigates back in history.
244-func (uc *NavigateUseCase) GoBack(ctx context.Context, webview port.WebView) error {
245- log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
246-
247- if !webview.CanGoBack() {
248- log.Debug().Msg("cannot go back - no history")
249- return nil
250- }
251-
252- log.Debug().Msg("navigating back")
253- return webview.GoBack(ctx)
254-}
255-
256-// GoForward navigates forward in history.
257-func (uc *NavigateUseCase) GoForward(ctx context.Context, webview port.WebView) error {
258- log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
259-
260- if !webview.CanGoForward() {
261- log.Debug().Msg("cannot go forward - no history")
262- return nil
263- }
264-
265- log.Debug().Msg("navigating forward")
266- return webview.GoForward(ctx)
267-}
268-
269 // Stop stops the current page load.243 // Stop stops the current page load.
270 func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error {244 func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error {
271 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()245 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
M internal/infrastructure/webkit/webview.go +26 -8
@@ -761,28 +761,46 @@ func (wv *WebView) Stop(ctx context.Context) error {
761761 }
762762
763763 // GoBack navigates back in history.
764+// Uses WebKit native navigation first, falls back to JavaScript history.back() for SPA compatibility.
764765 func (wv *WebView) GoBack(ctx context.Context) error {
765766 if wv.destroyed.Load() {
766767 return fmt.Errorf("webview %d is destroyed", wv.id)
767768 }
768- if !wv.inner.CanGoBack() {
769- return fmt.Errorf("cannot go back")
769+ log := logging.FromContext(ctx)
770+
771+ // Try WebKit native navigation first if available (for non-SPA pages)
772+ if wv.inner.CanGoBack() {
773+ wv.inner.GoBack()
774+ log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (native)")
775+ return nil
770776 }
771- wv.inner.GoBack()
772- logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("webview go back")
777+
778+ // Fall back to JavaScript history.back() for SPA navigation
779+ // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track
780+ wv.RunJavaScript(ctx, "history.back()", "")
781+ log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (js)")
773782 return nil
774783 }
775784
776785 // GoForward navigates forward in history.
786+// Uses WebKit native navigation first, falls back to JavaScript history.forward() for SPA compatibility.
777787 func (wv *WebView) GoForward(ctx context.Context) error {
778788 if wv.destroyed.Load() {
779789 return fmt.Errorf("webview %d is destroyed", wv.id)
780790 }
781- if !wv.inner.CanGoForward() {
782- return fmt.Errorf("cannot go forward")
791+ log := logging.FromContext(ctx)
792+
793+ // Try WebKit native navigation first if available (for non-SPA pages)
794+ if wv.inner.CanGoForward() {
795+ wv.inner.GoForward()
796+ log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (native)")
797+ return nil
783798 }
784- wv.inner.GoForward()
785- logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("webview go forward")
799+
800+ // Fall back to JavaScript history.forward() for SPA navigation
801+ // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track
802+ wv.RunJavaScript(ctx, "history.forward()", "")
803+ log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (js)")
786804 return nil
787805 }
788806
@@ -761,28 +761,46 @@ func (wv *WebView) Stop(ctx context.Context) error {
761 }761 }
762 762
763 // GoBack navigates back in history.763 // GoBack navigates back in history.
764+// Uses WebKit native navigation first, falls back to JavaScript history.back() for SPA compatibility.
764 func (wv *WebView) GoBack(ctx context.Context) error {765 func (wv *WebView) GoBack(ctx context.Context) error {
765 if wv.destroyed.Load() {766 if wv.destroyed.Load() {
766 return fmt.Errorf("webview %d is destroyed", wv.id)767 return fmt.Errorf("webview %d is destroyed", wv.id)
767 }768 }
768- if !wv.inner.CanGoBack() {769+ log := logging.FromContext(ctx)
769- return fmt.Errorf("cannot go back")770+
771+ // Try WebKit native navigation first if available (for non-SPA pages)
772+ if wv.inner.CanGoBack() {
773+ wv.inner.GoBack()
774+ log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (native)")
775+ return nil
770 }776 }
771- wv.inner.GoBack()777+
772- logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("webview go back")778+ // Fall back to JavaScript history.back() for SPA navigation
779+ // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track
780+ wv.RunJavaScript(ctx, "history.back()", "")
781+ log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (js)")
773 return nil782 return nil
774 }783 }
775 784
776 // GoForward navigates forward in history.785 // GoForward navigates forward in history.
786+// Uses WebKit native navigation first, falls back to JavaScript history.forward() for SPA compatibility.
777 func (wv *WebView) GoForward(ctx context.Context) error {787 func (wv *WebView) GoForward(ctx context.Context) error {
778 if wv.destroyed.Load() {788 if wv.destroyed.Load() {
779 return fmt.Errorf("webview %d is destroyed", wv.id)789 return fmt.Errorf("webview %d is destroyed", wv.id)
780 }790 }
781- if !wv.inner.CanGoForward() {791+ log := logging.FromContext(ctx)
782- return fmt.Errorf("cannot go forward")792+
793+ // Try WebKit native navigation first if available (for non-SPA pages)
794+ if wv.inner.CanGoForward() {
795+ wv.inner.GoForward()
796+ log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (native)")
797+ return nil
783 }798 }
784- wv.inner.GoForward()799+
785- logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("webview go forward")800+ // Fall back to JavaScript history.forward() for SPA navigation
801+ // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track
802+ wv.RunJavaScript(ctx, "history.forward()", "")
803+ log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (js)")
786 return nil804 return nil
787 }805 }
788 806
M internal/ui/coordinator/navigation.go +2 -8
@@ -133,6 +133,7 @@ func (c *NavigationCoordinator) HardReload(ctx context.Context) error {
133133 }
134134
135135 // GoBack navigates back in history.
136+// Calls webview directly - the webview layer handles SPA navigation via JS fallback.
136137 func (c *NavigationCoordinator) GoBack(ctx context.Context) error {
137138 log := logging.FromContext(ctx)
138139
@@ -142,14 +143,11 @@ func (c *NavigationCoordinator) GoBack(ctx context.Context) error {
142143 return nil
143144 }
144145
145- if c.navigateUC != nil {
146- return c.navigateUC.GoBack(ctx, wv)
147- }
148-
149146 return wv.GoBack(ctx)
150147 }
151148
152149 // GoForward navigates forward in history.
150+// Calls webview directly - the webview layer handles SPA navigation via JS fallback.
153151 func (c *NavigationCoordinator) GoForward(ctx context.Context) error {
154152 log := logging.FromContext(ctx)
155153
@@ -159,10 +157,6 @@ func (c *NavigationCoordinator) GoForward(ctx context.Context) error {
159157 return nil
160158 }
161159
162- if c.navigateUC != nil {
163- return c.navigateUC.GoForward(ctx, wv)
164- }
165-
166160 return wv.GoForward(ctx)
167161 }
168162
@@ -133,6 +133,7 @@ func (c *NavigationCoordinator) HardReload(ctx context.Context) error {
133 }133 }
134 134
135 // GoBack navigates back in history.135 // GoBack navigates back in history.
136+// Calls webview directly - the webview layer handles SPA navigation via JS fallback.
136 func (c *NavigationCoordinator) GoBack(ctx context.Context) error {137 func (c *NavigationCoordinator) GoBack(ctx context.Context) error {
137 log := logging.FromContext(ctx)138 log := logging.FromContext(ctx)
138 139
@@ -142,14 +143,11 @@ func (c *NavigationCoordinator) GoBack(ctx context.Context) error {
142 return nil143 return nil
143 }144 }
144 145
145- if c.navigateUC != nil {
146- return c.navigateUC.GoBack(ctx, wv)
147- }
148-
149 return wv.GoBack(ctx)146 return wv.GoBack(ctx)
150 }147 }
151 148
152 // GoForward navigates forward in history.149 // GoForward navigates forward in history.
150+// Calls webview directly - the webview layer handles SPA navigation via JS fallback.
153 func (c *NavigationCoordinator) GoForward(ctx context.Context) error {151 func (c *NavigationCoordinator) GoForward(ctx context.Context) error {
154 log := logging.FromContext(ctx)152 log := logging.FromContext(ctx)
155 153
@@ -159,10 +157,6 @@ func (c *NavigationCoordinator) GoForward(ctx context.Context) error {
159 return nil157 return nil
160 }158 }
161 159
162- if c.navigateUC != nil {
163- return c.navigateUC.GoForward(ctx, wv)
164- }
165-
166 return wv.GoForward(ctx)160 return wv.GoForward(ctx)
167 }161 }
168 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