detached 4ac613d

fix(favicon): address follow-up review findings

commit
4ac613dcd9c03de4eafe8a0a364af9f0ae2a3b96
author
brice · · 2026-06-07 19:08 UTC
parent
9f6779c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +69 -18
M internal/application/usecase/favicon.go +3 -0
@@ -386,6 +386,9 @@ func (uc *FaviconUseCase) EnsureSized(ctx context.Context, key favicon.Key, size
386386 if err != nil {
387387 return err
388388 }
389+ if uc.converter == nil {
390+ return ErrFaviconMiss
391+ }
389392 converted, err := uc.converter.Convert(ctx, orig, ct, []int{size})
390393 if err != nil {
391394 return err
@@ -386,6 +386,9 @@ func (uc *FaviconUseCase) EnsureSized(ctx context.Context, key favicon.Key, size
386 if err != nil {386 if err != nil {
387 return err387 return err
388 }388 }
389+ if uc.converter == nil {
390+ return ErrFaviconMiss
391+ }
389 converted, err := uc.converter.Convert(ctx, orig, ct, []int{size})392 converted, err := uc.converter.Convert(ctx, orig, ct, []int{size})
390 if err != nil {393 if err != nil {
391 return err394 return err
M internal/application/usecase/favicon_test.go +12 -0
@@ -55,6 +55,18 @@ func TestFaviconResolveRepairsOrphanCanonicalBlobMetadata(t *testing.T) {
5555 }
5656 }
5757
58+func TestFaviconResolveRepairOrphanWithoutConverterReturnsMiss(t *testing.T) {
59+ fx := newFaviconFixture()
60+ fx.uc.converter = nil
61+ fx.blobs.original["example.com"] = []byte("original")
62+ fx.blobs.contentTypes["example.com"] = "image/png"
63+
64+ _, err := fx.uc.ResolveSystemviewIcon(context.Background(), "example.com", SystemviewIconSize)
65+ if !errors.Is(err, ErrFaviconMiss) {
66+ t.Fatalf("expected favicon miss, got %v", err)
67+ }
68+}
69+
5870 func TestFaviconResolveStaleSchedulesDedupedBackgroundRefresh(t *testing.T) {
5971 fx := newFaviconFixture()
6072 fx.seed("example.com", []byte("old"), true)
@@ -55,6 +55,18 @@ func TestFaviconResolveRepairsOrphanCanonicalBlobMetadata(t *testing.T) {
55 }55 }
56 }56 }
57 57
58+func TestFaviconResolveRepairOrphanWithoutConverterReturnsMiss(t *testing.T) {
59+ fx := newFaviconFixture()
60+ fx.uc.converter = nil
61+ fx.blobs.original["example.com"] = []byte("original")
62+ fx.blobs.contentTypes["example.com"] = "image/png"
63+
64+ _, err := fx.uc.ResolveSystemviewIcon(context.Background(), "example.com", SystemviewIconSize)
65+ if !errors.Is(err, ErrFaviconMiss) {
66+ t.Fatalf("expected favicon miss, got %v", err)
67+ }
68+}
69+
58 func TestFaviconResolveStaleSchedulesDedupedBackgroundRefresh(t *testing.T) {70 func TestFaviconResolveStaleSchedulesDedupedBackgroundRefresh(t *testing.T) {
59 fx := newFaviconFixture()71 fx := newFaviconFixture()
60 fx.seed("example.com", []byte("old"), true)72 fx.seed("example.com", []byte("old"), true)
M internal/infrastructure/cef/webview_handlers.go +7 -4
@@ -387,11 +387,14 @@ func (wv *WebView) retainFaviconSourceVisitor(visitor purecef.StringVisitor) fun
387387 wv.faviconSourceVisitorsMu.Lock()
388388 defer wv.faviconSourceVisitorsMu.Unlock()
389389 for i, candidate := range wv.faviconSourceVisitors {
390- if candidate.id == id {
391- wv.faviconSourceVisitors[i].visitor = nil
392- wv.faviconSourceVisitors = append(wv.faviconSourceVisitors[:i], wv.faviconSourceVisitors[i+1:]...)
393- return
390+ if candidate.id != id {
391+ continue
394392 }
393+ last := len(wv.faviconSourceVisitors) - 1
394+ copy(wv.faviconSourceVisitors[i:], wv.faviconSourceVisitors[i+1:])
395+ wv.faviconSourceVisitors[last] = faviconSourceVisitorRetention{}
396+ wv.faviconSourceVisitors = wv.faviconSourceVisitors[:last]
397+ return
395398 }
396399 }
397400 }
@@ -387,11 +387,14 @@ func (wv *WebView) retainFaviconSourceVisitor(visitor purecef.StringVisitor) fun
387 wv.faviconSourceVisitorsMu.Lock()387 wv.faviconSourceVisitorsMu.Lock()
388 defer wv.faviconSourceVisitorsMu.Unlock()388 defer wv.faviconSourceVisitorsMu.Unlock()
389 for i, candidate := range wv.faviconSourceVisitors {389 for i, candidate := range wv.faviconSourceVisitors {
390- if candidate.id == id {390+ if candidate.id != id {
391- wv.faviconSourceVisitors[i].visitor = nil391+ continue
392- wv.faviconSourceVisitors = append(wv.faviconSourceVisitors[:i], wv.faviconSourceVisitors[i+1:]...)
393- return
394 }392 }
393+ last := len(wv.faviconSourceVisitors) - 1
394+ copy(wv.faviconSourceVisitors[i:], wv.faviconSourceVisitors[i+1:])
395+ wv.faviconSourceVisitors[last] = faviconSourceVisitorRetention{}
396+ wv.faviconSourceVisitors = wv.faviconSourceVisitors[:last]
397+ return
395 }398 }
396 }399 }
397 }400 }
M internal/ui/adapter/favicon.go +34 -14
@@ -23,6 +23,7 @@ type FaviconAdapter struct {
2323 resolver port.FaviconResolver
2424 faviconDB port.FaviconDatabase
2525 textureCache map[string]*gdk.Texture
26+ aliasesByKey map[favicon.Key]map[string]struct{}
2627 mu sync.RWMutex
2728 warnMu sync.Mutex
2829 warnCounts map[string]int
@@ -74,6 +75,7 @@ func NewFaviconAdapterWithResolver(
7475 resolver: resolver,
7576 faviconDB: faviconDB,
7677 textureCache: make(map[string]*gdk.Texture),
78+ aliasesByKey: make(map[favicon.Key]map[string]struct{}),
7779 warnCounts: make(map[string]int),
7880 isInternalURL: cfg.IsInternalURL,
7981 internalDomain: cfg.InternalDomain,
@@ -512,19 +514,15 @@ func (a *FaviconAdapter) Close() {
512514 }
513515 }
514516
515-// Invalidate clears one resolved-key texture cache entry and any domain aliases
516-// pointing at the same texture.
517+// Invalidate clears one resolved-key texture cache entry and all domain aliases
518+// explicitly bound to that canonical favicon key.
517519 func (a *FaviconAdapter) Invalidate(_ context.Context, key favicon.Key) error {
518520 a.mu.Lock()
519- texture := a.textureCache[string(key)]
520521 delete(a.textureCache, string(key))
521- if texture != nil {
522- for cacheKey, cached := range a.textureCache {
523- if cached == texture {
524- delete(a.textureCache, cacheKey)
525- }
526- }
522+ for alias := range a.aliasesByKey[key] {
523+ delete(a.textureCache, alias)
527524 }
525+ delete(a.aliasesByKey, key)
528526 a.mu.Unlock()
529527 return nil
530528 }
@@ -533,6 +531,7 @@ func (a *FaviconAdapter) Invalidate(_ context.Context, key favicon.Key) error {
533531 func (a *FaviconAdapter) Clear() {
534532 a.mu.Lock()
535533 a.textureCache = make(map[string]*gdk.Texture)
534+ a.aliasesByKey = make(map[favicon.Key]map[string]struct{})
536535 a.mu.Unlock()
537536 }
538537
@@ -545,12 +544,33 @@ func (a *FaviconAdapter) Size() int {
545544 }
546545
547546 // setResolvedTexture stores resolver results under both the resolved favicon key
548-// and the current page domain. The domain alias keeps UI lookups fast while
549-// Invalidate removes aliases sharing the same texture during the migration path.
547+// and the current page domain. Aliases are indexed by canonical key so
548+// invalidation does not depend on pointer equality with the latest texture.
550549 func (a *FaviconAdapter) setResolvedTexture(pageURL string, key favicon.Key, texture *gdk.Texture) {
551- a.setTexture(string(key), texture)
552- if domain := domainurl.ExtractDomain(pageURL); domain != "" {
553- a.setTexture(domain, texture)
550+ if key == "" || texture == nil {
551+ return
552+ }
553+ domain := domainurl.ExtractDomain(pageURL)
554+ a.mu.Lock()
555+ defer a.mu.Unlock()
556+ a.textureCache[string(key)] = texture
557+ if domain == "" {
558+ return
559+ }
560+ a.removeAliasLocked(domain)
561+ a.textureCache[domain] = texture
562+ if a.aliasesByKey[key] == nil {
563+ a.aliasesByKey[key] = make(map[string]struct{})
564+ }
565+ a.aliasesByKey[key][domain] = struct{}{}
566+}
567+
568+func (a *FaviconAdapter) removeAliasLocked(alias string) {
569+ for key, aliases := range a.aliasesByKey {
570+ delete(aliases, alias)
571+ if len(aliases) == 0 {
572+ delete(a.aliasesByKey, key)
573+ }
554574 }
555575 }
556576
@@ -23,6 +23,7 @@ type FaviconAdapter struct {
23 resolver port.FaviconResolver23 resolver port.FaviconResolver
24 faviconDB port.FaviconDatabase24 faviconDB port.FaviconDatabase
25 textureCache map[string]*gdk.Texture25 textureCache map[string]*gdk.Texture
26+ aliasesByKey map[favicon.Key]map[string]struct{}
26 mu sync.RWMutex27 mu sync.RWMutex
27 warnMu sync.Mutex28 warnMu sync.Mutex
28 warnCounts map[string]int29 warnCounts map[string]int
@@ -74,6 +75,7 @@ func NewFaviconAdapterWithResolver(
74 resolver: resolver,75 resolver: resolver,
75 faviconDB: faviconDB,76 faviconDB: faviconDB,
76 textureCache: make(map[string]*gdk.Texture),77 textureCache: make(map[string]*gdk.Texture),
78+ aliasesByKey: make(map[favicon.Key]map[string]struct{}),
77 warnCounts: make(map[string]int),79 warnCounts: make(map[string]int),
78 isInternalURL: cfg.IsInternalURL,80 isInternalURL: cfg.IsInternalURL,
79 internalDomain: cfg.InternalDomain,81 internalDomain: cfg.InternalDomain,
@@ -512,19 +514,15 @@ func (a *FaviconAdapter) Close() {
512 }514 }
513 }515 }
514 516
515-// Invalidate clears one resolved-key texture cache entry and any domain aliases517+// Invalidate clears one resolved-key texture cache entry and all domain aliases
516-// pointing at the same texture.518+// explicitly bound to that canonical favicon key.
517 func (a *FaviconAdapter) Invalidate(_ context.Context, key favicon.Key) error {519 func (a *FaviconAdapter) Invalidate(_ context.Context, key favicon.Key) error {
518 a.mu.Lock()520 a.mu.Lock()
519- texture := a.textureCache[string(key)]
520 delete(a.textureCache, string(key))521 delete(a.textureCache, string(key))
521- if texture != nil {522+ for alias := range a.aliasesByKey[key] {
522- for cacheKey, cached := range a.textureCache {523+ delete(a.textureCache, alias)
523- if cached == texture {
524- delete(a.textureCache, cacheKey)
525- }
526- }
527 }524 }
525+ delete(a.aliasesByKey, key)
528 a.mu.Unlock()526 a.mu.Unlock()
529 return nil527 return nil
530 }528 }
@@ -533,6 +531,7 @@ func (a *FaviconAdapter) Invalidate(_ context.Context, key favicon.Key) error {
533 func (a *FaviconAdapter) Clear() {531 func (a *FaviconAdapter) Clear() {
534 a.mu.Lock()532 a.mu.Lock()
535 a.textureCache = make(map[string]*gdk.Texture)533 a.textureCache = make(map[string]*gdk.Texture)
534+ a.aliasesByKey = make(map[favicon.Key]map[string]struct{})
536 a.mu.Unlock()535 a.mu.Unlock()
537 }536 }
538 537
@@ -545,12 +544,33 @@ func (a *FaviconAdapter) Size() int {
545 }544 }
546 545
547 // setResolvedTexture stores resolver results under both the resolved favicon key546 // setResolvedTexture stores resolver results under both the resolved favicon key
548-// and the current page domain. The domain alias keeps UI lookups fast while547+// and the current page domain. Aliases are indexed by canonical key so
549-// Invalidate removes aliases sharing the same texture during the migration path.548+// invalidation does not depend on pointer equality with the latest texture.
550 func (a *FaviconAdapter) setResolvedTexture(pageURL string, key favicon.Key, texture *gdk.Texture) {549 func (a *FaviconAdapter) setResolvedTexture(pageURL string, key favicon.Key, texture *gdk.Texture) {
551- a.setTexture(string(key), texture)550+ if key == "" || texture == nil {
552- if domain := domainurl.ExtractDomain(pageURL); domain != "" {551+ return
553- a.setTexture(domain, texture)552+ }
553+ domain := domainurl.ExtractDomain(pageURL)
554+ a.mu.Lock()
555+ defer a.mu.Unlock()
556+ a.textureCache[string(key)] = texture
557+ if domain == "" {
558+ return
559+ }
560+ a.removeAliasLocked(domain)
561+ a.textureCache[domain] = texture
562+ if a.aliasesByKey[key] == nil {
563+ a.aliasesByKey[key] = make(map[string]struct{})
564+ }
565+ a.aliasesByKey[key][domain] = struct{}{}
566+}
567+
568+func (a *FaviconAdapter) removeAliasLocked(alias string) {
569+ for key, aliases := range a.aliasesByKey {
570+ delete(aliases, alias)
571+ if len(aliases) == 0 {
572+ delete(a.aliasesByKey, key)
573+ }
554 }574 }
555 }575 }
556 576
M internal/ui/adapter/favicon_test.go +13 -0
@@ -26,6 +26,19 @@ func TestFaviconAdapterInvalidateRemovesResolvedTextureAliases(t *testing.T) {
2626 }
2727 }
2828
29+func TestFaviconAdapterInvalidateRemovesOlderAliasesAfterKeyRebind(t *testing.T) {
30+ adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
31+ adapter.setResolvedTexture("https://docs.example.com/page", favicon.Key("example.com"), &gdk.Texture{})
32+ adapter.setResolvedTexture("https://blog.example.com/page", favicon.Key("example.com"), &gdk.Texture{})
33+
34+ if err := adapter.Invalidate(context.Background(), "example.com"); err != nil {
35+ t.Fatal(err)
36+ }
37+ if adapter.GetTexture("example.com") != nil || adapter.GetTexture("docs.example.com") != nil || adapter.GetTexture("blog.example.com") != nil {
38+ t.Fatal("expected invalidation to remove canonical key and all aliases")
39+ }
40+}
41+
2942 func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) {
3043 adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
3144
@@ -26,6 +26,19 @@ func TestFaviconAdapterInvalidateRemovesResolvedTextureAliases(t *testing.T) {
26 }26 }
27 }27 }
28 28
29+func TestFaviconAdapterInvalidateRemovesOlderAliasesAfterKeyRebind(t *testing.T) {
30+ adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
31+ adapter.setResolvedTexture("https://docs.example.com/page", favicon.Key("example.com"), &gdk.Texture{})
32+ adapter.setResolvedTexture("https://blog.example.com/page", favicon.Key("example.com"), &gdk.Texture{})
33+
34+ if err := adapter.Invalidate(context.Background(), "example.com"); err != nil {
35+ t.Fatal(err)
36+ }
37+ if adapter.GetTexture("example.com") != nil || adapter.GetTexture("docs.example.com") != nil || adapter.GetTexture("blog.example.com") != nil {
38+ t.Fatal("expected invalidation to remove canonical key and all aliases")
39+ }
40+}
41+
29 func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) {42 func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) {
30 adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})43 adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
31 44

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