diff --git a/go.mod b/go.mod index 52a74b8d73045e7219ba57920828c7ce954584eb..40c4a8ceb6704a586bbcfb5da15681b0c5619c92 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/emersion/go-pgpmail v0.2.2 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 github.com/google/uuid v1.6.0 + github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/knadh/go-pop3 v1.0.2 github.com/yuin/goldmark v1.8.2 github.com/yuin/gopher-lua v1.1.2 diff --git a/go.sum b/go.sum index 30ea1bf7d8f4439b4f7c7603f3cbf09021401ff2..a1b2937191a46b4b7681a7a71b154af081bfbf39 100644 --- a/go.sum +++ b/go.sum @@ -73,6 +73,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/knadh/go-pop3 v1.0.2 h1:gbdtwzEYedLVos/vpebM2d73NTyZxEgjgRJ4S77HlzM= github.com/knadh/go-pop3 v1.0.2/go.mod h1:3gKw2jmrEa1lYLVtP1yEoo6bkkJ4XHDySPy8xaSjG0s= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= diff --git a/view/html.go b/view/html.go index 6149db5ba42396abb8530a7d5d2ef05e2331130d..3f8737febd6f886d5b019477038bc819d23455e9 100644 --- a/view/html.go +++ b/view/html.go @@ -9,12 +9,12 @@ import ( "os" "regexp" "strings" - "sync" "time" "charm.land/lipgloss/v2" "github.com/floatpane/matcha/clib" "github.com/floatpane/matcha/theme" + lru "github.com/hashicorp/golang-lru/v2" ) func linkStyle() lipgloss.Style { @@ -264,8 +264,18 @@ func debugImageProtocol(format string, args ...interface{}) { } } +const remoteImageCacheSize = 20 + // remoteImageCache caches fetched remote images (URL -> base64 PNG string). -var remoteImageCache sync.Map +var remoteImageCache *lru.Cache[string, string] + +func init() { + c, err := lru.New[string, string](remoteImageCacheSize) + if err != nil { + panic(err) // only fails on size <= 0 + } + remoteImageCache = c +} // nextImageID is an auto-incrementing counter for Kitty image IDs. var nextImageID uint32 = 1000 @@ -283,9 +293,9 @@ func fetchRemoteBase64(url string) string { } // Check cache first - if cached, ok := remoteImageCache.Load(url); ok { + if cached, ok := remoteImageCache.Get(url); ok { debugImageProtocol("remote cache hit url=%s", url) - return cached.(string) + return cached } client := &http.Client{Timeout: 5 * time.Second} @@ -316,7 +326,7 @@ func fetchRemoteBase64(url string) string { encoded := base64.StdEncoding.EncodeToString(result.PNGData) debugImageProtocol("remote fetch ok url=%s len=%d", url, len(encoded)) - remoteImageCache.Store(url, encoded) + remoteImageCache.Add(url, encoded) return encoded } diff --git a/view/html_test.go b/view/html_test.go index 31f69b9bd0f0ffabf9a8f734ae45dc528c69c866..795e705baeba9e5e27aeff46c2d939f4e0b26d5e 100644 --- a/view/html_test.go +++ b/view/html_test.go @@ -1,6 +1,7 @@ package view import ( + "fmt" "os" "regexp" "strings" @@ -724,3 +725,39 @@ func TestProcessBody(t *testing.T) { }) } } + +func TestRemoteImageCache_EvictsOldestWhenFull(t *testing.T) { + // Start with a clean cache so prior tests don't interfere. + remoteImageCache.Purge() + // cleaning up the current test's cache + defer remoteImageCache.Purge() + + // overfilling the cache beyond its configured capacity. + overfillBy := 5 + totalInserts := remoteImageCacheSize + overfillBy + for i := range totalInserts { + url := fmt.Sprintf("https://example.com/img%d.png", i) + remoteImageCache.Add(url, "fake-base64-data") + } + + // cache should not be overfilled beyond it's capped size + if got := remoteImageCache.Len(); got != remoteImageCacheSize { + t.Errorf("expected cache size %d, got %d", remoteImageCacheSize, got) + } + + // old entries should be evicted + for i := range overfillBy { + evictedURL := fmt.Sprintf("https://example.com/img%d.png", i) + if _, ok := remoteImageCache.Get(evictedURL); ok { + t.Errorf("expected %q to be evicted, but it's still in cache", evictedURL) + } + } + + // The most recent entries should still be present. + for i := overfillBy; i < totalInserts; i++ { + keptURL := fmt.Sprintf("https://example.com/img%d.png", i) + if _, ok := remoteImageCache.Get(keptURL); !ok { + t.Errorf("expected %q to still be in cache", keptURL) + } + } +}