detached f9e2d43

feat(app): wire favicon support through coordinators and omnibox

Initialize FaviconCache in App and inject into ContentCoordinator and Omnibox. Wire favicon callbacks in ContentCoordinator to track page favicons and update cache. Track navigation origins in NavigationCoordinator to handle cross-domain redirects (e.g., google.fr to google.com). Refactor Omnibox row creation to unified createRowWithFavicon method supporting async favicon loading from cache with icon fallback. Display favicon images in suggestion and favorite rows.
commit
f9e2d438a9b7afb8427f28634e24031afb42e1fa
author
brice · · 2025-12-15 18:19 UTC
committer
brice · · 2025-12-15 21:53 UTC
parent
458bd84
signature
unsigned
4 files · +165 -49
M internal/ui/app.go +15 -4
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/dumber/internal/domain/entity"
1414 "github.com/bnema/dumber/internal/infrastructure/webkit"
1515 "github.com/bnema/dumber/internal/logging"
16+ "github.com/bnema/dumber/internal/ui/cache"
1617 "github.com/bnema/dumber/internal/ui/component"
1718 "github.com/bnema/dumber/internal/ui/coordinator"
1819 "github.com/bnema/dumber/internal/ui/dispatcher"
@@ -61,10 +62,11 @@ type App struct {
6162 omnibox *component.Omnibox
6263
6364 // Web content (managed by ContentCoordinator)
64- pool *webkit.WebViewPool
65- injector *webkit.ContentInjector
66- router *webkit.MessageRouter
67- settings *webkit.SettingsManager
65+ pool *webkit.WebViewPool
66+ injector *webkit.ContentInjector
67+ router *webkit.MessageRouter
68+ settings *webkit.SettingsManager
69+ faviconCache *cache.FaviconCache
6870
6971 // ID generator for tabs/panes
7072 idCounter uint64
@@ -190,6 +192,7 @@ func (a *App) onActivate(ctx context.Context) {
190192 a.omnibox = component.NewOmnibox(ctx, a.mainWindow.Window(), component.OmniboxConfig{
191193 HistoryUC: a.deps.HistoryUC,
192194 FavoritesUC: a.deps.FavoritesUC,
195+ FaviconCache: a.faviconCache,
193196 Shortcuts: a.deps.Config.SearchShortcuts,
194197 DefaultSearch: a.deps.Config.DefaultSearchEngine,
195198 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
@@ -241,11 +244,19 @@ func (a *App) initCoordinators(ctx context.Context) {
241244 return a.activeWorkspace(), a.activeWorkspaceView()
242245 }
243246
247+ // Create FaviconCache with FaviconDatabase from WebKitContext
248+ if a.deps.WebContext != nil {
249+ a.faviconCache = cache.NewFaviconCache(a.deps.WebContext.FaviconDatabase())
250+ } else {
251+ a.faviconCache = cache.NewFaviconCache(nil)
252+ }
253+
244254 // 1. Content Coordinator (no dependencies on other coordinators)
245255 a.contentCoord = coordinator.NewContentCoordinator(
246256 ctx,
247257 a.pool,
248258 a.widgetFactory,
259+ a.faviconCache,
249260 getActiveWS,
250261 )
251262
@@ -13,6 +13,7 @@ import (
13 "github.com/bnema/dumber/internal/domain/entity"13 "github.com/bnema/dumber/internal/domain/entity"
14 "github.com/bnema/dumber/internal/infrastructure/webkit"14 "github.com/bnema/dumber/internal/infrastructure/webkit"
15 "github.com/bnema/dumber/internal/logging"15 "github.com/bnema/dumber/internal/logging"
16+ "github.com/bnema/dumber/internal/ui/cache"
16 "github.com/bnema/dumber/internal/ui/component"17 "github.com/bnema/dumber/internal/ui/component"
17 "github.com/bnema/dumber/internal/ui/coordinator"18 "github.com/bnema/dumber/internal/ui/coordinator"
18 "github.com/bnema/dumber/internal/ui/dispatcher"19 "github.com/bnema/dumber/internal/ui/dispatcher"
@@ -61,10 +62,11 @@ type App struct {
61 omnibox *component.Omnibox62 omnibox *component.Omnibox
62 63
63 // Web content (managed by ContentCoordinator)64 // Web content (managed by ContentCoordinator)
64- pool *webkit.WebViewPool65+ pool *webkit.WebViewPool
65- injector *webkit.ContentInjector66+ injector *webkit.ContentInjector
66- router *webkit.MessageRouter67+ router *webkit.MessageRouter
67- settings *webkit.SettingsManager68+ settings *webkit.SettingsManager
69+ faviconCache *cache.FaviconCache
68 70
69 // ID generator for tabs/panes71 // ID generator for tabs/panes
70 idCounter uint6472 idCounter uint64
@@ -190,6 +192,7 @@ func (a *App) onActivate(ctx context.Context) {
190 a.omnibox = component.NewOmnibox(ctx, a.mainWindow.Window(), component.OmniboxConfig{192 a.omnibox = component.NewOmnibox(ctx, a.mainWindow.Window(), component.OmniboxConfig{
191 HistoryUC: a.deps.HistoryUC,193 HistoryUC: a.deps.HistoryUC,
192 FavoritesUC: a.deps.FavoritesUC,194 FavoritesUC: a.deps.FavoritesUC,
195+ FaviconCache: a.faviconCache,
193 Shortcuts: a.deps.Config.SearchShortcuts,196 Shortcuts: a.deps.Config.SearchShortcuts,
194 DefaultSearch: a.deps.Config.DefaultSearchEngine,197 DefaultSearch: a.deps.Config.DefaultSearchEngine,
195 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,198 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
@@ -241,11 +244,19 @@ func (a *App) initCoordinators(ctx context.Context) {
241 return a.activeWorkspace(), a.activeWorkspaceView()244 return a.activeWorkspace(), a.activeWorkspaceView()
242 }245 }
243 246
247+ // Create FaviconCache with FaviconDatabase from WebKitContext
248+ if a.deps.WebContext != nil {
249+ a.faviconCache = cache.NewFaviconCache(a.deps.WebContext.FaviconDatabase())
250+ } else {
251+ a.faviconCache = cache.NewFaviconCache(nil)
252+ }
253+
244 // 1. Content Coordinator (no dependencies on other coordinators)254 // 1. Content Coordinator (no dependencies on other coordinators)
245 a.contentCoord = coordinator.NewContentCoordinator(255 a.contentCoord = coordinator.NewContentCoordinator(
246 ctx,256 ctx,
247 a.pool,257 a.pool,
248 a.widgetFactory,258 a.widgetFactory,
259+ a.faviconCache,
249 getActiveWS,260 getActiveWS,
250 )261 )
251 262
M internal/ui/component/omnibox.go +43 -45
@@ -10,6 +10,7 @@ import (
1010 "github.com/bnema/dumber/internal/domain/entity"
1111 "github.com/bnema/dumber/internal/infrastructure/config"
1212 "github.com/bnema/dumber/internal/logging"
13+ "github.com/bnema/dumber/internal/ui/cache"
1314 "github.com/jwijenbergh/puregotk/v4/gdk"
1415 "github.com/jwijenbergh/puregotk/v4/glib"
1516 "github.com/jwijenbergh/puregotk/v4/gtk"
@@ -71,6 +72,7 @@ type Omnibox struct {
7172 // Dependencies
7273 historyUC *usecase.SearchHistoryUseCase
7374 favoritesUC *usecase.ManageFavoritesUseCase
75+ faviconCache *cache.FaviconCache
7476 shortcuts map[string]config.SearchShortcut
7577 defaultSearch string
7678 initialBehavior string
@@ -90,6 +92,7 @@ type Omnibox struct {
9092 type OmniboxConfig struct {
9193 HistoryUC *usecase.SearchHistoryUseCase
9294 FavoritesUC *usecase.ManageFavoritesUseCase
95+ FaviconCache *cache.FaviconCache
9396 Shortcuts map[string]config.SearchShortcut
9497 DefaultSearch string
9598 InitialBehavior string
@@ -105,6 +108,7 @@ func NewOmnibox(ctx context.Context, parent *gtk.ApplicationWindow, cfg OmniboxC
105108 selectedIndex: -1,
106109 historyUC: cfg.HistoryUC,
107110 favoritesUC: cfg.FavoritesUC,
111+ faviconCache: cfg.FaviconCache,
108112 shortcuts: cfg.Shortcuts,
109113 defaultSearch: cfg.DefaultSearch,
110114 initialBehavior: cfg.InitialBehavior,
@@ -535,55 +539,34 @@ func (o *Omnibox) rebuildList() {
535539 }
536540 }
537541
538-// createSuggestionRow creates a ListBoxRow for a suggestion.
539-func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
540- row := gtk.NewListBoxRow()
541- if row == nil {
542+// createFaviconImage creates a favicon image with async loading from cache.
543+func (o *Omnibox) createFaviconImage(url, fallbackIcon string) *gtk.Image {
544+ favicon := gtk.NewImage()
545+ if favicon == nil {
542546 return nil
543547 }
544- row.AddCssClass("omnibox-row")
545-
546- box := gtk.NewBox(gtk.OrientationHorizontalValue, 8)
547- if box == nil {
548- return nil
549- }
550- box.SetHexpand(true)
551-
552- // URL/Title label
553- displayText := s.Title
554- if displayText == "" {
555- displayText = s.URL
556- }
557- label := gtk.NewLabel(nil)
558- if label != nil {
559- label.SetText(displayText) // Explicitly set text
560- label.AddCssClass("omnibox-suggestion-title")
561- label.SetHalign(gtk.AlignStartValue)
562- label.SetHexpand(true)
563- label.SetEllipsize(2) // PANGO_ELLIPSIZE_END
564- box.Append(&label.Widget)
565- }
566-
567- // Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th)
568- if index <= 9 {
569- shortcutLabel := gtk.NewLabel(nil)
570- if shortcutLabel != nil {
571- if index < 9 {
572- shortcutLabel.SetText(formatShortcut(index + 1))
573- } else {
574- shortcutLabel.SetText("Ctrl+0")
548+ favicon.SetFromIconName(&fallbackIcon)
549+ favicon.SetPixelSize(16)
550+ favicon.AddCssClass("omnibox-favicon")
551+
552+ // Async load favicon from cache
553+ if o.faviconCache != nil && url != "" {
554+ o.faviconCache.GetOrFetch(o.ctx, url, func(texture *gdk.Texture) {
555+ if texture != nil {
556+ var cb glib.SourceFunc = func(data uintptr) bool {
557+ favicon.SetFromPaintable(texture)
558+ return false
559+ }
560+ glib.IdleAdd(&cb, 0)
575561 }
576- shortcutLabel.AddCssClass("omnibox-shortcut-badge")
577- box.Append(&shortcutLabel.Widget)
578- }
562+ })
579563 }
580564
581- row.SetChild(&box.Widget)
582- return row
565+ return favicon
583566 }
584567
585-// createFavoriteRow creates a ListBoxRow for a favorite.
586-func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
568+// createRowWithFavicon creates a ListBoxRow with favicon, title, and shortcut badge.
569+func (o *Omnibox) createRowWithFavicon(url, title, fallbackIcon string, index int) *gtk.ListBoxRow {
587570 row := gtk.NewListBoxRow()
588571 if row == nil {
589572 return nil
@@ -596,10 +579,15 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
596579 }
597580 box.SetHexpand(true)
598581
582+ // Favicon image
583+ if favicon := o.createFaviconImage(url, fallbackIcon); favicon != nil {
584+ box.Append(&favicon.Widget)
585+ }
586+
599587 // Title label
600- displayText := f.Title
588+ displayText := title
601589 if displayText == "" {
602- displayText = f.URL
590+ displayText = url
603591 }
604592 label := gtk.NewLabel(nil)
605593 if label != nil {
@@ -607,7 +595,7 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
607595 label.AddCssClass("omnibox-suggestion-title")
608596 label.SetHalign(gtk.AlignStartValue)
609597 label.SetHexpand(true)
610- label.SetEllipsize(2)
598+ label.SetEllipsize(2) // PANGO_ELLIPSIZE_END
611599 box.Append(&label.Widget)
612600 }
613601
@@ -629,6 +617,16 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
629617 return row
630618 }
631619
620+// createSuggestionRow creates a ListBoxRow for a suggestion.
621+func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
622+ return o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)
623+}
624+
625+// createFavoriteRow creates a ListBoxRow for a favorite.
626+func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
627+ return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index)
628+}
629+
632630 // selectIndex selects a row by index.
633631 func (o *Omnibox) selectIndex(index int) {
634632 // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb
@@ -10,6 +10,7 @@ import (
10 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
11 "github.com/bnema/dumber/internal/infrastructure/config"11 "github.com/bnema/dumber/internal/infrastructure/config"
12 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
13+ "github.com/bnema/dumber/internal/ui/cache"
13 "github.com/jwijenbergh/puregotk/v4/gdk"14 "github.com/jwijenbergh/puregotk/v4/gdk"
14 "github.com/jwijenbergh/puregotk/v4/glib"15 "github.com/jwijenbergh/puregotk/v4/glib"
15 "github.com/jwijenbergh/puregotk/v4/gtk"16 "github.com/jwijenbergh/puregotk/v4/gtk"
@@ -71,6 +72,7 @@ type Omnibox struct {
71 // Dependencies72 // Dependencies
72 historyUC *usecase.SearchHistoryUseCase73 historyUC *usecase.SearchHistoryUseCase
73 favoritesUC *usecase.ManageFavoritesUseCase74 favoritesUC *usecase.ManageFavoritesUseCase
75+ faviconCache *cache.FaviconCache
74 shortcuts map[string]config.SearchShortcut76 shortcuts map[string]config.SearchShortcut
75 defaultSearch string77 defaultSearch string
76 initialBehavior string78 initialBehavior string
@@ -90,6 +92,7 @@ type Omnibox struct {
90 type OmniboxConfig struct {92 type OmniboxConfig struct {
91 HistoryUC *usecase.SearchHistoryUseCase93 HistoryUC *usecase.SearchHistoryUseCase
92 FavoritesUC *usecase.ManageFavoritesUseCase94 FavoritesUC *usecase.ManageFavoritesUseCase
95+ FaviconCache *cache.FaviconCache
93 Shortcuts map[string]config.SearchShortcut96 Shortcuts map[string]config.SearchShortcut
94 DefaultSearch string97 DefaultSearch string
95 InitialBehavior string98 InitialBehavior string
@@ -105,6 +108,7 @@ func NewOmnibox(ctx context.Context, parent *gtk.ApplicationWindow, cfg OmniboxC
105 selectedIndex: -1,108 selectedIndex: -1,
106 historyUC: cfg.HistoryUC,109 historyUC: cfg.HistoryUC,
107 favoritesUC: cfg.FavoritesUC,110 favoritesUC: cfg.FavoritesUC,
111+ faviconCache: cfg.FaviconCache,
108 shortcuts: cfg.Shortcuts,112 shortcuts: cfg.Shortcuts,
109 defaultSearch: cfg.DefaultSearch,113 defaultSearch: cfg.DefaultSearch,
110 initialBehavior: cfg.InitialBehavior,114 initialBehavior: cfg.InitialBehavior,
@@ -535,55 +539,34 @@ func (o *Omnibox) rebuildList() {
535 }539 }
536 }540 }
537 541
538-// createSuggestionRow creates a ListBoxRow for a suggestion.542+// createFaviconImage creates a favicon image with async loading from cache.
539-func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {543+func (o *Omnibox) createFaviconImage(url, fallbackIcon string) *gtk.Image {
540- row := gtk.NewListBoxRow()544+ favicon := gtk.NewImage()
541- if row == nil {545+ if favicon == nil {
542 return nil546 return nil
543 }547 }
544- row.AddCssClass("omnibox-row")548+ favicon.SetFromIconName(&fallbackIcon)
545-549+ favicon.SetPixelSize(16)
546- box := gtk.NewBox(gtk.OrientationHorizontalValue, 8)550+ favicon.AddCssClass("omnibox-favicon")
547- if box == nil {551+
548- return nil552+ // Async load favicon from cache
549- }553+ if o.faviconCache != nil && url != "" {
550- box.SetHexpand(true)554+ o.faviconCache.GetOrFetch(o.ctx, url, func(texture *gdk.Texture) {
551-555+ if texture != nil {
552- // URL/Title label556+ var cb glib.SourceFunc = func(data uintptr) bool {
553- displayText := s.Title557+ favicon.SetFromPaintable(texture)
554- if displayText == "" {558+ return false
555- displayText = s.URL559+ }
556- }560+ glib.IdleAdd(&cb, 0)
557- label := gtk.NewLabel(nil)
558- if label != nil {
559- label.SetText(displayText) // Explicitly set text
560- label.AddCssClass("omnibox-suggestion-title")
561- label.SetHalign(gtk.AlignStartValue)
562- label.SetHexpand(true)
563- label.SetEllipsize(2) // PANGO_ELLIPSIZE_END
564- box.Append(&label.Widget)
565- }
566-
567- // Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th)
568- if index <= 9 {
569- shortcutLabel := gtk.NewLabel(nil)
570- if shortcutLabel != nil {
571- if index < 9 {
572- shortcutLabel.SetText(formatShortcut(index + 1))
573- } else {
574- shortcutLabel.SetText("Ctrl+0")
575 }561 }
576- shortcutLabel.AddCssClass("omnibox-shortcut-badge")562+ })
577- box.Append(&shortcutLabel.Widget)
578- }
579 }563 }
580 564
581- row.SetChild(&box.Widget)565+ return favicon
582- return row
583 }566 }
584 567
585-// createFavoriteRow creates a ListBoxRow for a favorite.568+// createRowWithFavicon creates a ListBoxRow with favicon, title, and shortcut badge.
586-func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {569+func (o *Omnibox) createRowWithFavicon(url, title, fallbackIcon string, index int) *gtk.ListBoxRow {
587 row := gtk.NewListBoxRow()570 row := gtk.NewListBoxRow()
588 if row == nil {571 if row == nil {
589 return nil572 return nil
@@ -596,10 +579,15 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
596 }579 }
597 box.SetHexpand(true)580 box.SetHexpand(true)
598 581
582+ // Favicon image
583+ if favicon := o.createFaviconImage(url, fallbackIcon); favicon != nil {
584+ box.Append(&favicon.Widget)
585+ }
586+
599 // Title label587 // Title label
600- displayText := f.Title588+ displayText := title
601 if displayText == "" {589 if displayText == "" {
602- displayText = f.URL590+ displayText = url
603 }591 }
604 label := gtk.NewLabel(nil)592 label := gtk.NewLabel(nil)
605 if label != nil {593 if label != nil {
@@ -607,7 +595,7 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
607 label.AddCssClass("omnibox-suggestion-title")595 label.AddCssClass("omnibox-suggestion-title")
608 label.SetHalign(gtk.AlignStartValue)596 label.SetHalign(gtk.AlignStartValue)
609 label.SetHexpand(true)597 label.SetHexpand(true)
610- label.SetEllipsize(2)598+ label.SetEllipsize(2) // PANGO_ELLIPSIZE_END
611 box.Append(&label.Widget)599 box.Append(&label.Widget)
612 }600 }
613 601
@@ -629,6 +617,16 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
629 return row617 return row
630 }618 }
631 619
620+// createSuggestionRow creates a ListBoxRow for a suggestion.
621+func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
622+ return o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)
623+}
624+
625+// createFavoriteRow creates a ListBoxRow for a favorite.
626+func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
627+ return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index)
628+}
629+
632 // selectIndex selects a row by index.630 // selectIndex selects a row by index.
633 func (o *Omnibox) selectIndex(index int) {631 func (o *Omnibox) selectIndex(index int) {
634 // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb632 // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb
M internal/ui/coordinator/content.go +105 -0
@@ -8,19 +8,27 @@ import (
88 "github.com/bnema/dumber/internal/domain/entity"
99 "github.com/bnema/dumber/internal/infrastructure/webkit"
1010 "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/dumber/internal/ui/cache"
1112 "github.com/bnema/dumber/internal/ui/component"
1213 "github.com/bnema/dumber/internal/ui/layout"
14+ "github.com/jwijenbergh/puregotk/v4/gdk"
1315 )
1416
1517 // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.
1618 type ContentCoordinator struct {
1719 pool *webkit.WebViewPool
1820 widgetFactory layout.WidgetFactory
21+ faviconCache *cache.FaviconCache
1922
2023 webViews map[entity.PaneID]*webkit.WebView
2124 paneTitles map[entity.PaneID]string
2225 titleMu sync.RWMutex
2326
27+ // Track original navigation URLs to handle cross-domain redirects
28+ // e.g., google.fr → google.com: cache favicon under both domains
29+ navOrigins map[entity.PaneID]string
30+ navOriginMu sync.RWMutex
31+
2432 // Callback to get active workspace state (avoids circular dependency)
2533 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
2634 }
@@ -30,6 +38,7 @@ func NewContentCoordinator(
3038 ctx context.Context,
3139 pool *webkit.WebViewPool,
3240 widgetFactory layout.WidgetFactory,
41+ faviconCache *cache.FaviconCache,
3342 getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
3443 ) *ContentCoordinator {
3544 log := logging.FromContext(ctx)
@@ -38,8 +47,10 @@ func NewContentCoordinator(
3847 return &ContentCoordinator{
3948 pool: pool,
4049 widgetFactory: widgetFactory,
50+ faviconCache: faviconCache,
4151 webViews: make(map[entity.PaneID]*webkit.WebView),
4252 paneTitles: make(map[entity.PaneID]string),
53+ navOrigins: make(map[entity.PaneID]string),
4354 getActiveWS: getActiveWS,
4455 }
4556 }
@@ -68,6 +79,11 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
6879 c.onTitleChanged(ctx, paneID, title)
6980 }
7081
82+ // Set up favicon change callback
83+ wv.OnFaviconChanged = func(favicon *gdk.Texture) {
84+ c.onFaviconChanged(ctx, paneID, favicon)
85+ }
86+
7187 log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")
7288 return wv, nil
7389 }
@@ -87,6 +103,11 @@ func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.P
87103 delete(c.paneTitles, paneID)
88104 c.titleMu.Unlock()
89105
106+ // Clean up navigation origin tracking
107+ c.navOriginMu.Lock()
108+ delete(c.navOrigins, paneID)
109+ c.navOriginMu.Unlock()
110+
90111 if c.pool != nil {
91112 c.pool.Release(wv)
92113 } else {
@@ -241,3 +262,87 @@ func (c *ContentCoordinator) updateStackedPaneTitle(ctx context.Context, ws *ent
241262 }
242263 }
243264 }
265+
266+// onFaviconChanged updates favicon tracking when a WebView's favicon changes.
267+func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity.PaneID, favicon *gdk.Texture) {
268+ log := logging.FromContext(ctx)
269+
270+ // Get current URI to extract domain for caching
271+ wv := c.webViews[paneID]
272+ if wv == nil {
273+ return
274+ }
275+ uri := wv.URI()
276+
277+ // Update favicon cache with domain key (final URL after redirects)
278+ if c.faviconCache != nil && favicon != nil && uri != "" {
279+ c.faviconCache.SetByURL(uri, favicon)
280+
281+ // Also cache under original navigation URL to handle cross-domain redirects
282+ // e.g., google.fr → google.com: cache favicon under both domains
283+ c.navOriginMu.RLock()
284+ originURL := c.navOrigins[paneID]
285+ c.navOriginMu.RUnlock()
286+ if originURL != "" && originURL != uri {
287+ c.faviconCache.SetByURL(originURL, favicon)
288+ }
289+ }
290+
291+ // Update StackedView favicon if this pane is in a stack
292+ ws, wsView := c.getActiveWS()
293+ if wsView != nil {
294+ tr := wsView.TreeRenderer()
295+ if tr != nil {
296+ stackedView := tr.GetStackedViewForPane(string(paneID))
297+ if stackedView != nil {
298+ c.updateStackedPaneFavicon(ctx, ws, stackedView, paneID, favicon)
299+ }
300+ }
301+ }
302+
303+ log.Debug().
304+ Str("pane_id", string(paneID)).
305+ Str("uri", uri).
306+ Bool("has_favicon", favicon != nil).
307+ Msg("pane favicon updated")
308+}
309+
310+// updateStackedPaneFavicon updates the favicon of a pane in a StackedView.
311+func (c *ContentCoordinator) updateStackedPaneFavicon(ctx context.Context, ws *entity.Workspace, sv *layout.StackedView, paneID entity.PaneID, favicon *gdk.Texture) {
312+ log := logging.FromContext(ctx)
313+
314+ if ws == nil {
315+ return
316+ }
317+
318+ paneNode := ws.FindPane(paneID)
319+ if paneNode == nil {
320+ return
321+ }
322+
323+ // If the pane is in a stacked parent, find its index
324+ if paneNode.Parent != nil && paneNode.Parent.IsStacked {
325+ for i, child := range paneNode.Parent.Children {
326+ if child.Pane != nil && child.Pane.ID == paneID {
327+ if err := sv.UpdateFaviconTexture(i, favicon); err != nil {
328+ log.Warn().Err(err).Int("index", i).Msg("failed to update stacked pane favicon")
329+ }
330+ return
331+ }
332+ }
333+ }
334+}
335+
336+// FaviconCache returns the favicon cache for external use (e.g., omnibox).
337+func (c *ContentCoordinator) FaviconCache() *cache.FaviconCache {
338+ return c.faviconCache
339+}
340+
341+// SetNavigationOrigin records the original URL before navigation starts.
342+// This allows caching favicons under both original and final domains
343+// when cross-domain redirects occur (e.g., google.fr → google.com).
344+func (c *ContentCoordinator) SetNavigationOrigin(paneID entity.PaneID, url string) {
345+ c.navOriginMu.Lock()
346+ c.navOrigins[paneID] = url
347+ c.navOriginMu.Unlock()
348+}
@@ -8,19 +8,27 @@ import (
8 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/infrastructure/webkit"9 "github.com/bnema/dumber/internal/infrastructure/webkit"
10 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/dumber/internal/ui/cache"
11 "github.com/bnema/dumber/internal/ui/component"12 "github.com/bnema/dumber/internal/ui/component"
12 "github.com/bnema/dumber/internal/ui/layout"13 "github.com/bnema/dumber/internal/ui/layout"
14+ "github.com/jwijenbergh/puregotk/v4/gdk"
13 )15 )
14 16
15 // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.17 // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.
16 type ContentCoordinator struct {18 type ContentCoordinator struct {
17 pool *webkit.WebViewPool19 pool *webkit.WebViewPool
18 widgetFactory layout.WidgetFactory20 widgetFactory layout.WidgetFactory
21+ faviconCache *cache.FaviconCache
19 22
20 webViews map[entity.PaneID]*webkit.WebView23 webViews map[entity.PaneID]*webkit.WebView
21 paneTitles map[entity.PaneID]string24 paneTitles map[entity.PaneID]string
22 titleMu sync.RWMutex25 titleMu sync.RWMutex
23 26
27+ // Track original navigation URLs to handle cross-domain redirects
28+ // e.g., google.fr → google.com: cache favicon under both domains
29+ navOrigins map[entity.PaneID]string
30+ navOriginMu sync.RWMutex
31+
24 // Callback to get active workspace state (avoids circular dependency)32 // Callback to get active workspace state (avoids circular dependency)
25 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)33 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
26 }34 }
@@ -30,6 +38,7 @@ func NewContentCoordinator(
30 ctx context.Context,38 ctx context.Context,
31 pool *webkit.WebViewPool,39 pool *webkit.WebViewPool,
32 widgetFactory layout.WidgetFactory,40 widgetFactory layout.WidgetFactory,
41+ faviconCache *cache.FaviconCache,
33 getActiveWS func() (*entity.Workspace, *component.WorkspaceView),42 getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
34 ) *ContentCoordinator {43 ) *ContentCoordinator {
35 log := logging.FromContext(ctx)44 log := logging.FromContext(ctx)
@@ -38,8 +47,10 @@ func NewContentCoordinator(
38 return &ContentCoordinator{47 return &ContentCoordinator{
39 pool: pool,48 pool: pool,
40 widgetFactory: widgetFactory,49 widgetFactory: widgetFactory,
50+ faviconCache: faviconCache,
41 webViews: make(map[entity.PaneID]*webkit.WebView),51 webViews: make(map[entity.PaneID]*webkit.WebView),
42 paneTitles: make(map[entity.PaneID]string),52 paneTitles: make(map[entity.PaneID]string),
53+ navOrigins: make(map[entity.PaneID]string),
43 getActiveWS: getActiveWS,54 getActiveWS: getActiveWS,
44 }55 }
45 }56 }
@@ -68,6 +79,11 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
68 c.onTitleChanged(ctx, paneID, title)79 c.onTitleChanged(ctx, paneID, title)
69 }80 }
70 81
82+ // Set up favicon change callback
83+ wv.OnFaviconChanged = func(favicon *gdk.Texture) {
84+ c.onFaviconChanged(ctx, paneID, favicon)
85+ }
86+
71 log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")87 log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")
72 return wv, nil88 return wv, nil
73 }89 }
@@ -87,6 +103,11 @@ func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.P
87 delete(c.paneTitles, paneID)103 delete(c.paneTitles, paneID)
88 c.titleMu.Unlock()104 c.titleMu.Unlock()
89 105
106+ // Clean up navigation origin tracking
107+ c.navOriginMu.Lock()
108+ delete(c.navOrigins, paneID)
109+ c.navOriginMu.Unlock()
110+
90 if c.pool != nil {111 if c.pool != nil {
91 c.pool.Release(wv)112 c.pool.Release(wv)
92 } else {113 } else {
@@ -241,3 +262,87 @@ func (c *ContentCoordinator) updateStackedPaneTitle(ctx context.Context, ws *ent
241 }262 }
242 }263 }
243 }264 }
265+
266+// onFaviconChanged updates favicon tracking when a WebView's favicon changes.
267+func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity.PaneID, favicon *gdk.Texture) {
268+ log := logging.FromContext(ctx)
269+
270+ // Get current URI to extract domain for caching
271+ wv := c.webViews[paneID]
272+ if wv == nil {
273+ return
274+ }
275+ uri := wv.URI()
276+
277+ // Update favicon cache with domain key (final URL after redirects)
278+ if c.faviconCache != nil && favicon != nil && uri != "" {
279+ c.faviconCache.SetByURL(uri, favicon)
280+
281+ // Also cache under original navigation URL to handle cross-domain redirects
282+ // e.g., google.fr → google.com: cache favicon under both domains
283+ c.navOriginMu.RLock()
284+ originURL := c.navOrigins[paneID]
285+ c.navOriginMu.RUnlock()
286+ if originURL != "" && originURL != uri {
287+ c.faviconCache.SetByURL(originURL, favicon)
288+ }
289+ }
290+
291+ // Update StackedView favicon if this pane is in a stack
292+ ws, wsView := c.getActiveWS()
293+ if wsView != nil {
294+ tr := wsView.TreeRenderer()
295+ if tr != nil {
296+ stackedView := tr.GetStackedViewForPane(string(paneID))
297+ if stackedView != nil {
298+ c.updateStackedPaneFavicon(ctx, ws, stackedView, paneID, favicon)
299+ }
300+ }
301+ }
302+
303+ log.Debug().
304+ Str("pane_id", string(paneID)).
305+ Str("uri", uri).
306+ Bool("has_favicon", favicon != nil).
307+ Msg("pane favicon updated")
308+}
309+
310+// updateStackedPaneFavicon updates the favicon of a pane in a StackedView.
311+func (c *ContentCoordinator) updateStackedPaneFavicon(ctx context.Context, ws *entity.Workspace, sv *layout.StackedView, paneID entity.PaneID, favicon *gdk.Texture) {
312+ log := logging.FromContext(ctx)
313+
314+ if ws == nil {
315+ return
316+ }
317+
318+ paneNode := ws.FindPane(paneID)
319+ if paneNode == nil {
320+ return
321+ }
322+
323+ // If the pane is in a stacked parent, find its index
324+ if paneNode.Parent != nil && paneNode.Parent.IsStacked {
325+ for i, child := range paneNode.Parent.Children {
326+ if child.Pane != nil && child.Pane.ID == paneID {
327+ if err := sv.UpdateFaviconTexture(i, favicon); err != nil {
328+ log.Warn().Err(err).Int("index", i).Msg("failed to update stacked pane favicon")
329+ }
330+ return
331+ }
332+ }
333+ }
334+}
335+
336+// FaviconCache returns the favicon cache for external use (e.g., omnibox).
337+func (c *ContentCoordinator) FaviconCache() *cache.FaviconCache {
338+ return c.faviconCache
339+}
340+
341+// SetNavigationOrigin records the original URL before navigation starts.
342+// This allows caching favicons under both original and final domains
343+// when cross-domain redirects occur (e.g., google.fr → google.com).
344+func (c *ContentCoordinator) SetNavigationOrigin(paneID entity.PaneID, url string) {
345+ c.navOriginMu.Lock()
346+ c.navOrigins[paneID] = url
347+ c.navOriginMu.Unlock()
348+}
M internal/ui/coordinator/navigation.go +2 -0
@@ -55,6 +55,8 @@ func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error
5555 if ws != nil {
5656 if pane := ws.ActivePane(); pane != nil && pane.Pane != nil {
5757 paneID = string(pane.Pane.ID)
58+ // Track original URL for cross-domain redirect favicon caching
59+ c.contentCoord.SetNavigationOrigin(pane.Pane.ID, url)
5860 }
5961 }
6062
@@ -55,6 +55,8 @@ func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error
55 if ws != nil {55 if ws != nil {
56 if pane := ws.ActivePane(); pane != nil && pane.Pane != nil {56 if pane := ws.ActivePane(); pane != nil && pane.Pane != nil {
57 paneID = string(pane.Pane.ID)57 paneID = string(pane.Pane.ID)
58+ // Track original URL for cross-domain redirect favicon caching
59+ c.contentCoord.SetNavigationOrigin(pane.Pane.ID, url)
58 }60 }
59 }61 }
60 62

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