detached e30e9ed

fix(history): record full URL instead of domain on navigation

Move history recording from navigation start to LoadCommitted event when URI
is guaranteed correct. Add SPA navigation detection via OnURIChanged callback
for JavaScript-based URL changes like YouTube search. Wires all necessary
callbacks to properly save full URLs to history instead of just the domain.

Changes:
- webkit/webview.go: Add notify::uri signal handler to track URI changes
- usecase/navigate.go: Rename recordHistory to public RecordHistory, remove
  history recording from Execute, fix UpdateHistoryTitle to not create
  duplicate entries
- coordinator/navigation.go: Add RecordHistory method to wire history recording
- coordinator/content.go: Add onHistoryRecord callback, SetOnHistoryRecord
  setter, wire OnURIChanged for SPA navigation, add onSPANavigation handler
- ui/app.go: Wire history recording callback in initCoordinators
commit
e30e9ed1775f6067b4ec8542c099c2b14871ee06
author
brice · · 2025-12-16 20:36 UTC
committer
brice · · 2025-12-16 17:18 UTC
parent
ec9a433
signature
unsigned
5 files · +79 -14
M internal/application/usecase/navigate.go +8 -10
@@ -57,13 +57,11 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N
5757 Msg("navigating to URL")
5858
5959 // Navigate to URL (zoom will be applied on LoadCommitted to avoid shift)
60+ // History is recorded on LoadCommitted when URI is guaranteed correct
6061 if err := input.WebView.LoadURI(ctx, input.URL); err != nil {
6162 return nil, fmt.Errorf("failed to load URL: %w", err)
6263 }
6364
64- // Record in history asynchronously
65- go uc.recordHistory(ctx, input.URL)
66-
6765 log.Info().
6866 Str("url", input.URL).
6967 Msg("navigation initiated")
@@ -73,8 +71,9 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N
7371 }, nil
7472 }
7573
76-// recordHistory saves or updates the history entry.
77-func (uc *NavigateUseCase) recordHistory(ctx context.Context, url string) {
74+// RecordHistory saves or updates the history entry.
75+// Should be called on LoadCommitted when URI is guaranteed correct.
76+func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
7877 log := logging.FromContext(ctx)
7978
8079 // Normalize URL to avoid duplicates (e.g., github.com vs github.com/)
@@ -115,11 +114,10 @@ func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title st
115114 }
116115
117116 if entry == nil {
118- // Entry doesn't exist, create it with title
119- entry = entity.NewHistoryEntry(url, title)
120- if err := uc.historyRepo.Save(ctx, entry); err != nil {
121- return fmt.Errorf("failed to save history: %w", err)
122- }
117+ // Entry doesn't exist - don't create it here
118+ // Initial navigation should have already created the entry
119+ // This can happen if URL changed during page load (SPA, redirect)
120+ log.Debug().Str("url", url).Msg("no history entry found for URL, skipping title update")
123121 return nil
124122 }
125123
@@ -57,13 +57,11 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N
57 Msg("navigating to URL")57 Msg("navigating to URL")
58 58
59 // Navigate to URL (zoom will be applied on LoadCommitted to avoid shift)59 // Navigate to URL (zoom will be applied on LoadCommitted to avoid shift)
60+ // History is recorded on LoadCommitted when URI is guaranteed correct
60 if err := input.WebView.LoadURI(ctx, input.URL); err != nil {61 if err := input.WebView.LoadURI(ctx, input.URL); err != nil {
61 return nil, fmt.Errorf("failed to load URL: %w", err)62 return nil, fmt.Errorf("failed to load URL: %w", err)
62 }63 }
63 64
64- // Record in history asynchronously
65- go uc.recordHistory(ctx, input.URL)
66-
67 log.Info().65 log.Info().
68 Str("url", input.URL).66 Str("url", input.URL).
69 Msg("navigation initiated")67 Msg("navigation initiated")
@@ -73,8 +71,9 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N
73 }, nil71 }, nil
74 }72 }
75 73
76-// recordHistory saves or updates the history entry.74+// RecordHistory saves or updates the history entry.
77-func (uc *NavigateUseCase) recordHistory(ctx context.Context, url string) {75+// Should be called on LoadCommitted when URI is guaranteed correct.
76+func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
78 log := logging.FromContext(ctx)77 log := logging.FromContext(ctx)
79 78
80 // Normalize URL to avoid duplicates (e.g., github.com vs github.com/)79 // Normalize URL to avoid duplicates (e.g., github.com vs github.com/)
@@ -115,11 +114,10 @@ func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title st
115 }114 }
116 115
117 if entry == nil {116 if entry == nil {
118- // Entry doesn't exist, create it with title117+ // Entry doesn't exist - don't create it here
119- entry = entity.NewHistoryEntry(url, title)118+ // Initial navigation should have already created the entry
120- if err := uc.historyRepo.Save(ctx, entry); err != nil {119+ // This can happen if URL changed during page load (SPA, redirect)
121- return fmt.Errorf("failed to save history: %w", err)120+ log.Debug().Str("url", url).Msg("no history entry found for URL, skipping title update")
122- }
123 return nil121 return nil
124 }122 }
125 123
M internal/infrastructure/webkit/webview.go +16 -0
@@ -296,6 +296,22 @@ func (wv *WebView) connectSignals() {
296296 sigID = gobject.SignalConnect(wv.inner.GoPointer(), "notify::title", glib.NewCallback(&titleCb))
297297 wv.signalIDs = append(wv.signalIDs, sigID)
298298
299+ // notify::uri signal for URI changes
300+ // This fires when the URI changes (e.g., redirects, SPA navigation via History API)
301+ uriCb := func() {
302+ uri := wv.inner.GetUri()
303+ wv.mu.Lock()
304+ oldUri := wv.uri
305+ wv.uri = uri
306+ wv.mu.Unlock()
307+
308+ if wv.OnURIChanged != nil && uri != oldUri {
309+ wv.OnURIChanged(uri)
310+ }
311+ }
312+ sigID = gobject.SignalConnect(wv.inner.GoPointer(), "notify::uri", glib.NewCallback(&uriCb))
313+ wv.signalIDs = append(wv.signalIDs, sigID)
314+
299315 // notify::favicon signal for favicon changes
300316 // This fires when the page favicon changes (after page load or dynamic updates)
301317 faviconCb := func() {
@@ -296,6 +296,22 @@ func (wv *WebView) connectSignals() {
296 sigID = gobject.SignalConnect(wv.inner.GoPointer(), "notify::title", glib.NewCallback(&titleCb))296 sigID = gobject.SignalConnect(wv.inner.GoPointer(), "notify::title", glib.NewCallback(&titleCb))
297 wv.signalIDs = append(wv.signalIDs, sigID)297 wv.signalIDs = append(wv.signalIDs, sigID)
298 298
299+ // notify::uri signal for URI changes
300+ // This fires when the URI changes (e.g., redirects, SPA navigation via History API)
301+ uriCb := func() {
302+ uri := wv.inner.GetUri()
303+ wv.mu.Lock()
304+ oldUri := wv.uri
305+ wv.uri = uri
306+ wv.mu.Unlock()
307+
308+ if wv.OnURIChanged != nil && uri != oldUri {
309+ wv.OnURIChanged(uri)
310+ }
311+ }
312+ sigID = gobject.SignalConnect(wv.inner.GoPointer(), "notify::uri", glib.NewCallback(&uriCb))
313+ wv.signalIDs = append(wv.signalIDs, sigID)
314+
299 // notify::favicon signal for favicon changes315 // notify::favicon signal for favicon changes
300 // This fires when the page favicon changes (after page load or dynamic updates)316 // This fires when the page favicon changes (after page load or dynamic updates)
301 faviconCb := func() {317 faviconCb := func() {
M internal/ui/app.go +5 -0
@@ -310,6 +310,11 @@ func (a *App) initCoordinators(ctx context.Context) {
310310 a.navCoord.UpdateHistoryTitle(ctx, paneID, url, title)
311311 })
312312
313+ // Wire history recording on LoadCommitted (URI is guaranteed correct at this point)
314+ a.contentCoord.SetOnHistoryRecord(func(ctx context.Context, paneID entity.PaneID, url string) {
315+ a.navCoord.RecordHistory(ctx, paneID, url)
316+ })
317+
313318 // 5. Keyboard Dispatcher
314319 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
315320 ctx,
@@ -310,6 +310,11 @@ func (a *App) initCoordinators(ctx context.Context) {
310 a.navCoord.UpdateHistoryTitle(ctx, paneID, url, title)310 a.navCoord.UpdateHistoryTitle(ctx, paneID, url, title)
311 })311 })
312 312
313+ // Wire history recording on LoadCommitted (URI is guaranteed correct at this point)
314+ a.contentCoord.SetOnHistoryRecord(func(ctx context.Context, paneID entity.PaneID, url string) {
315+ a.navCoord.RecordHistory(ctx, paneID, url)
316+ })
317+
313 // 5. Keyboard Dispatcher318 // 5. Keyboard Dispatcher
314 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(319 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
315 ctx,320 ctx,
M internal/ui/coordinator/content.go +41 -4
@@ -38,6 +38,9 @@ type ContentCoordinator struct {
3838 // Callback when title changes (for history persistence)
3939 onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)
4040
41+ // Callback when page is committed (for history recording)
42+ onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string)
43+
4144 // Gesture action handler for mouse button navigation
4245 gestureActionHandler input.ActionHandler
4346 }
@@ -71,6 +74,11 @@ func (c *ContentCoordinator) SetOnTitleUpdated(fn func(ctx context.Context, pane
7174 c.onTitleUpdated = fn
7275 }
7376
77+// SetOnHistoryRecord sets the callback for recording history on page commit.
78+func (c *ContentCoordinator) SetOnHistoryRecord(fn func(ctx context.Context, paneID entity.PaneID, url string)) {
79+ c.onHistoryRecord = fn
80+}
81+
7482 // SetGestureActionHandler sets the callback for mouse button navigation gestures.
7583 func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) {
7684 c.gestureActionHandler = handler
@@ -112,6 +120,16 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
112120 }
113121 }
114122
123+ // Set up URI change callback for SPA navigation (History API)
124+ // This fires when URL changes via JavaScript without a full page load
125+ wv.OnURIChanged = func(uri string) {
126+ // Only record if not loading (SPA navigation via History API)
127+ // Full page loads are handled by OnLoadCommitted
128+ if !wv.IsLoading() && uri != "" {
129+ c.onSPANavigation(ctx, paneID, uri)
130+ }
131+ }
132+
115133 log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")
116134 return wv, nil
117135 }
@@ -423,15 +441,22 @@ func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID en
423441 }
424442 }
425443
426-// onLoadCommitted re-applies zoom when page content starts loading.
444+// onLoadCommitted re-applies zoom when page content starts loading and records history.
427445 // WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.
446+// History is recorded here because the URI is guaranteed to be correct after commit.
428447 func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
429- if c.zoomUC == nil {
448+ url := wv.URI()
449+ if url == "" {
430450 return
431451 }
432452
433- url := wv.URI()
434- if url == "" {
453+ // Record history - URI is guaranteed to be correct at LoadCommitted
454+ if c.onHistoryRecord != nil {
455+ c.onHistoryRecord(ctx, paneID, url)
456+ }
457+
458+ // Apply zoom
459+ if c.zoomUC == nil {
435460 return
436461 }
437462
@@ -442,3 +467,15 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
442467
443468 _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
444469 }
470+
471+// onSPANavigation records history when URL changes via JavaScript (History API).
472+// This handles SPA navigation like YouTube search, where the URL changes without a page load.
473+func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, url string) {
474+ log := logging.FromContext(ctx)
475+ log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("SPA navigation detected")
476+
477+ // Record history for SPA navigation
478+ if c.onHistoryRecord != nil {
479+ c.onHistoryRecord(ctx, paneID, url)
480+ }
481+}
@@ -38,6 +38,9 @@ type ContentCoordinator struct {
38 // Callback when title changes (for history persistence)38 // Callback when title changes (for history persistence)
39 onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)39 onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)
40 40
41+ // Callback when page is committed (for history recording)
42+ onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string)
43+
41 // Gesture action handler for mouse button navigation44 // Gesture action handler for mouse button navigation
42 gestureActionHandler input.ActionHandler45 gestureActionHandler input.ActionHandler
43 }46 }
@@ -71,6 +74,11 @@ func (c *ContentCoordinator) SetOnTitleUpdated(fn func(ctx context.Context, pane
71 c.onTitleUpdated = fn74 c.onTitleUpdated = fn
72 }75 }
73 76
77+// SetOnHistoryRecord sets the callback for recording history on page commit.
78+func (c *ContentCoordinator) SetOnHistoryRecord(fn func(ctx context.Context, paneID entity.PaneID, url string)) {
79+ c.onHistoryRecord = fn
80+}
81+
74 // SetGestureActionHandler sets the callback for mouse button navigation gestures.82 // SetGestureActionHandler sets the callback for mouse button navigation gestures.
75 func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) {83 func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) {
76 c.gestureActionHandler = handler84 c.gestureActionHandler = handler
@@ -112,6 +120,16 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
112 }120 }
113 }121 }
114 122
123+ // Set up URI change callback for SPA navigation (History API)
124+ // This fires when URL changes via JavaScript without a full page load
125+ wv.OnURIChanged = func(uri string) {
126+ // Only record if not loading (SPA navigation via History API)
127+ // Full page loads are handled by OnLoadCommitted
128+ if !wv.IsLoading() && uri != "" {
129+ c.onSPANavigation(ctx, paneID, uri)
130+ }
131+ }
132+
115 log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")133 log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")
116 return wv, nil134 return wv, nil
117 }135 }
@@ -423,15 +441,22 @@ func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID en
423 }441 }
424 }442 }
425 443
426-// onLoadCommitted re-applies zoom when page content starts loading.444+// onLoadCommitted re-applies zoom when page content starts loading and records history.
427 // WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.445 // WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.
446+// History is recorded here because the URI is guaranteed to be correct after commit.
428 func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {447 func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
429- if c.zoomUC == nil {448+ url := wv.URI()
449+ if url == "" {
430 return450 return
431 }451 }
432 452
433- url := wv.URI()453+ // Record history - URI is guaranteed to be correct at LoadCommitted
434- if url == "" {454+ if c.onHistoryRecord != nil {
455+ c.onHistoryRecord(ctx, paneID, url)
456+ }
457+
458+ // Apply zoom
459+ if c.zoomUC == nil {
435 return460 return
436 }461 }
437 462
@@ -442,3 +467,15 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
442 467
443 _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)468 _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
444 }469 }
470+
471+// onSPANavigation records history when URL changes via JavaScript (History API).
472+// This handles SPA navigation like YouTube search, where the URL changes without a page load.
473+func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, url string) {
474+ log := logging.FromContext(ctx)
475+ log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("SPA navigation detected")
476+
477+ // Record history for SPA navigation
478+ if c.onHistoryRecord != nil {
479+ c.onHistoryRecord(ctx, paneID, url)
480+ }
481+}
M internal/ui/coordinator/navigation.go +9 -0
@@ -213,6 +213,15 @@ func (c *NavigationCoordinator) UpdateHistoryTitle(ctx context.Context, paneID e
213213 }
214214 }
215215
216+// RecordHistory records a URL in history on page commit.
217+func (c *NavigationCoordinator) RecordHistory(ctx context.Context, paneID entity.PaneID, url string) {
218+ if c.navigateUC == nil {
219+ return
220+ }
221+
222+ c.navigateUC.RecordHistory(ctx, url)
223+}
224+
216225 // ActiveWebView returns the WebView for the active pane (for zoom operations).
217226 func (c *NavigationCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView {
218227 return c.contentCoord.ActiveWebView(ctx)
@@ -213,6 +213,15 @@ func (c *NavigationCoordinator) UpdateHistoryTitle(ctx context.Context, paneID e
213 }213 }
214 }214 }
215 215
216+// RecordHistory records a URL in history on page commit.
217+func (c *NavigationCoordinator) RecordHistory(ctx context.Context, paneID entity.PaneID, url string) {
218+ if c.navigateUC == nil {
219+ return
220+ }
221+
222+ c.navigateUC.RecordHistory(ctx, url)
223+}
224+
216 // ActiveWebView returns the WebView for the active pane (for zoom operations).225 // ActiveWebView returns the WebView for the active pane (for zoom operations).
217 func (c *NavigationCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView {226 func (c *NavigationCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView {
218 return c.contentCoord.ActiveWebView(ctx)227 return c.contentCoord.ActiveWebView(ctx)

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