From c1cab60a0b36fef2ceab6c593461407f334a1465 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 25 Aug 2026 20:22:13 +0700 Subject: [PATCH] update image cache --- crates/workspace/src/image_cache.rs | 226 +++++++----------- .../src/views/repo_detail/issue_detail.rs | 5 +- .../workspace/src/views/repo_detail/issues.rs | 8 +- crates/workspace/src/views/repo_detail/mod.rs | 7 +- .../views/repo_detail/pull_request_detail.rs | 6 +- .../src/views/repo_detail/pull_requests.rs | 8 +- crates/workspace/src/views/repo_list.rs | 2 + crates/workspace/src/views/sidebar/mod.rs | 2 + crates/workspace/src/workspace.rs | 3 +- desktop/src/main.rs | 4 - 10 files changed, 107 insertions(+), 164 deletions(-) diff --git a/crates/workspace/src/image_cache.rs b/crates/workspace/src/image_cache.rs index 283f89b..58d0ff2 100644 --- a/crates/workspace/src/image_cache.rs +++ b/crates/workspace/src/image_cache.rs @@ -1,186 +1,136 @@ use std::collections::{HashMap, VecDeque}; use std::mem::take; -use std::sync::Arc; use futures::FutureExt; use gpui::{ - App, AppContext, Asset, AssetLogger, Entity, Global, ImageAssetLoader, ImageCache, - ImageCacheError, ImageCacheItem, ImageSource, RenderImage, Resource, Window, hash, + App, AppContext, Asset, AssetLogger, ElementId, Entity, ImageAssetLoader, ImageCache, + ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash, }; -/// Upper bound on the number of images the shared cache retains. Loading a -/// new image evicts the least recently used entry once this is reached. -const MAX_IMAGES: usize = 128; +/// Default number of images each view's cache retains. Loading a new image +/// evicts the least recently used entry once this is reached. +pub const MAX_IMAGES: usize = 128; -/// Global handle to the shared image cache, installed by [`init`]. -struct SharedImageCache(Entity); - -impl Global for SharedImageCache {} - -/// Create the shared image cache and install it as a global. Call once at -/// startup (see `desktop/src/main.rs`), before any window opens. -pub fn init(cx: &mut App) -> Entity { - let cache = LruImageCache::new(MAX_IMAGES, cx); - cx.set_global(SharedImageCache(cache.clone())); - cache +pub fn image_cache(id: impl Into, max_items: usize) -> AppImageCacheProvider { + AppImageCacheProvider { + id: id.into(), + max_items, + } } -/// The shared image cache. Panics if [`init`] hasn't been called. -pub fn global(cx: &App) -> Entity { - cx.global::().0.clone() -} - -/// Drop every cached image, freeing the decoded image data, the GPU textures -/// and the raw fetched bytes. Images currently on screen are re-fetched on -/// the next frame. -pub fn clear(cx: &mut App, window: &mut Window) { - let cache = global(cx); - cache.update(cx, |cache, cx| cache.clear(window, cx)); -} - -/// Clear the shared cache when `view` is released — its last strong -/// reference is gone, e.g. the user closed the panel it was rendered in. -pub fn clear_on_release(view: &Entity, window: &Window, cx: &mut App) { - let cache = global(cx).downgrade(); - cx.observe_release_in(view, window, move |_view, window, cx| { - if let Some(cache) = cache.upgrade() { - cache.update(cx, |cache, cx| cache.clear(window, cx)); - } - }) - .detach(); -} - -/// A bounded LRU image cache. -/// -/// Entries keep the [`Resource`] they were loaded from so that eviction, -/// clearing and release can also remove the asset from the asset system, -/// freeing the raw fetched bytes that would otherwise stay in memory. -pub struct LruImageCache { +pub struct AppImageCacheProvider { + id: ElementId, max_items: usize, - /// Most recently used hashes first. - usage: VecDeque, +} + +impl ImageCacheProvider for AppImageCacheProvider { + fn provide(&mut self, window: &mut gpui::Window, cx: &mut App) -> gpui::AnyImageCache { + window + .with_global_id(self.id.clone(), |id, window| { + window.with_element_state(id, |cache, _| { + let cache = cache.unwrap_or_else(|| AppImageCache::new(self.max_items, cx)); + (cache.clone(), cache) + }) + }) + .into() + } +} + +pub struct AppImageCache { + max_items: usize, + usage_list: VecDeque, cache: HashMap, } -impl LruImageCache { - /// Create a cache that holds at most `max_items` images. Cached images - /// are dropped from every window when the cache is released. +impl AppImageCache { pub fn new(max_items: usize, cx: &mut App) -> Entity { - let max_items = max_items.max(1); cx.new(|cx| { + log::info!("Creating AppImageCacheProvider"); cx.on_release(|this: &mut Self, cx| { - for (_, entry) in take(&mut this.cache) { - unload(entry, None, cx); + for (ix, (mut image, resource)) in take(&mut this.cache) { + if let Some(Ok(image)) = image.get() { + log::info!("Dropping image {ix}"); + cx.drop_image(image, None); + } + ImageSource::Resource(resource).remove_asset(cx); } }) .detach(); - Self { + + AppImageCache { max_items, - usage: VecDeque::with_capacity(max_items), + usage_list: VecDeque::with_capacity(max_items), cache: HashMap::with_capacity(max_items), } }) } - - /// Drop every cached image and remove every cached asset. - pub fn clear(&mut self, window: &mut Window, cx: &mut App) { - self.usage.clear(); - for (_, entry) in take(&mut self.cache) { - unload(entry, Some(window), cx); - } - } } -/// Drop a cache entry's decoded data and remove its resource from the asset -/// system, so both the decoded image and the raw fetched bytes are freed. -/// -/// The atlas texture is freed **on the next frame, before it paints**, never -/// in the middle of one: the release that empties the cache can run at the -/// end of a frame's draw — after the scene was built, before it is presented -/// — and eviction runs while a frame is painting (`load` is called from -/// paint). The next frame also has to repaint every view instead of replaying -/// their recorded paint commands: `cached()` views reuse recorded commands -/// across frames, and those commands reference the atlas tiles being freed, -/// so a replay would hand the renderer a scene full of freed texture ids. -/// `window.refresh()` disables that reuse for exactly one frame. -/// -/// `window` restricts the atlas removal to the current window; `None` removes -/// it from all windows (the cache entity is being released at shutdown, when -/// no scene is in flight). -fn unload( - (mut item, resource): (ImageCacheItem, Resource), - window: Option<&mut Window>, - cx: &mut App, -) { - if let Some(Ok(image)) = item.get() { - match window { - Some(window) => { - window.on_next_frame(move |window, cx| { - window.refresh(); - cx.drop_image(image, Some(window)); - }); - } - None => cx.drop_image(image, None), - } - } - ImageSource::Resource(resource).remove_asset(cx); -} - -impl ImageCache for LruImageCache { +impl ImageCache for AppImageCache { fn load( &mut self, resource: &Resource, - window: &mut Window, - cx: &mut App, - ) -> Option, ImageCacheError>> { - debug_assert_eq!(self.usage.len(), self.cache.len()); - debug_assert!(self.cache.len() <= self.max_items); - + window: &mut gpui::Window, + cx: &mut gpui::App, + ) -> Option, gpui::ImageCacheError>> { let hash = hash(resource); - if let Some((item, _)) = self.cache.get_mut(&hash) { - let current_ix = self - .usage + if let Some(item) = self.cache.get_mut(&hash) { + let current_idx = self + .usage_list .iter() - .position(|used| *used == hash) - .expect("cache and usage list must stay in sync"); - self.usage.remove(current_ix); - self.usage.push_front(hash); - return item.get(); + .position(|item| *item == hash) + .expect("cache has an item usage_list doesn't"); + + self.usage_list.remove(current_idx); + self.usage_list.push_front(hash); + + return item.0.get(); } - let fut = AssetLogger::::load(resource.clone(), cx); - let task = cx.background_executor().spawn(fut).shared(); + let load_future = AssetLogger::::load(resource.clone(), cx); + let task = cx.background_executor().spawn(load_future).shared(); - if self.usage.len() >= self.max_items { - let oldest = self - .usage - .pop_back() - .expect("usage list and cache must stay in sync"); - let entry = self - .cache - .remove(&oldest) - .expect("usage list and cache must stay in sync"); - unload(entry, Some(window), cx); + if self.usage_list.len() >= self.max_items { + log::info!("Image cache is full, evicting oldest item"); + + if let Some(oldest) = self.usage_list.pop_back() { + let mut image = self + .cache + .remove(&oldest) + .expect("usage_list has an item cache doesn't"); + + if let Some(Ok(image)) = image.0.get() { + log::info!("requesting image to be dropped"); + cx.drop_image(image, Some(window)); + } + + ImageSource::Resource(image.1).remove_asset(cx); + } } self.cache.insert( hash, - (ImageCacheItem::Loading(task.clone()), resource.clone()), + ( + gpui::ImageCacheItem::Loading(task.clone()), + resource.clone(), + ), ); - self.usage.push_front(hash); + self.usage_list.push_front(hash); let entity = window.current_view(); + window - .spawn(cx, { - async move |cx| { - if let Err(error) = task.await { - log::error!("failed to load image into cache: {:?}", error); - } - cx.on_next_frame(move |_, cx| { - cx.notify(entity); - }); + .spawn(cx, async move |cx| { + let result = task.await; + + if let Err(err) = result { + log::error!("error loading image into cache: {:?}", err); } + + cx.on_next_frame(move |_, cx| { + cx.notify(entity); + }); }) .detach(); diff --git a/crates/workspace/src/views/repo_detail/issue_detail.rs b/crates/workspace/src/views/repo_detail/issue_detail.rs index ecd9213..7eb0977 100644 --- a/crates/workspace/src/views/repo_detail/issue_detail.rs +++ b/crates/workspace/src/views/repo_detail/issue_detail.rs @@ -17,6 +17,7 @@ use signed_state::{ProfileStore, RepoStore}; use utils::relative_time; use super::helpers::{placeholder, status_badge}; +use crate::image_cache::{MAX_IMAGES, image_cache}; /// Detail panel of a single issue. pub struct IssueDetailView { @@ -35,9 +36,6 @@ impl IssueDetailView { window: &mut Window, cx: &mut Context, ) -> Self { - // Issue author avatars stay in the shared cache until the panel closes. - crate::image_cache::clear_on_release(&cx.entity(), window, cx); - let comment_input = cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment…")); @@ -299,6 +297,7 @@ impl Render for IssueDetailView { }; h_flex() + .image_cache(image_cache("issue-detail", MAX_IMAGES)) .id("issue-detail") .size_full() .child( diff --git a/crates/workspace/src/views/repo_detail/issues.rs b/crates/workspace/src/views/repo_detail/issues.rs index 880b3a9..7a4e9a1 100644 --- a/crates/workspace/src/views/repo_detail/issues.rs +++ b/crates/workspace/src/views/repo_detail/issues.rs @@ -28,6 +28,7 @@ use utils::relative_time; use super::helpers::{placeholder, status_badge}; use super::issue_detail::IssueDetailView; +use crate::image_cache::{MAX_IMAGES, image_cache}; /// Height of one issue row in the virtual list: 8px vertical padding /// (`py_2`) on top and bottom, a 32px title line (`h_8`) and a 24px meta @@ -88,13 +89,9 @@ impl IssuesView { dock_area: WeakEntity, store: Entity, repo_name: SharedString, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) -> Self { - // Issue author avatars stay in the shared cache until the panel - // closes; free them then. - crate::image_cache::clear_on_release(&cx.entity(), window, cx); - Self { focus_handle: cx.focus_handle(), dock_area, @@ -468,6 +465,7 @@ impl Render for IssuesView { v_flex() .size_full() + .image_cache(image_cache("issues", MAX_IMAGES)) .child(self.render_header(cx)) .child( v_flex() diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index ae1fbf1..4c6d3c3 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -27,6 +27,8 @@ use signed_core::Announcement; use signed_git::{CommitList, FileCommit}; use signed_state::{GitStore, ProfileStore, RepoStore}; +use crate::image_cache::{MAX_IMAGES, image_cache}; + mod browser; mod commits; mod diff; @@ -145,10 +147,6 @@ impl RepoDetailView { window: &mut Window, cx: &mut Context, ) -> Self { - // Owner/maintainer avatars shown by this view stay in the shared - // cache until the panel closes; free them then. - crate::image_cache::clear_on_release(&cx.entity(), window, cx); - // The announcement we opened from already carries the repository's // NIP-34 `relays` tag, so the store can connect to those relays // immediately instead of waiting for the bootstrap fetch. @@ -1239,6 +1237,7 @@ impl Render for RepoDetailView { .unwrap_or_else(|| "Overview".into()); v_flex() + .image_cache(image_cache("repo", MAX_IMAGES)) .id("repo") .size_full() .child(self.render_header(cx)) diff --git a/crates/workspace/src/views/repo_detail/pull_request_detail.rs b/crates/workspace/src/views/repo_detail/pull_request_detail.rs index d16919a..31560fb 100644 --- a/crates/workspace/src/views/repo_detail/pull_request_detail.rs +++ b/crates/workspace/src/views/repo_detail/pull_request_detail.rs @@ -33,6 +33,7 @@ use super::helpers::{ DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, placeholder, render_diff_row, status_badge, tree_items, tree_row, }; +use crate::image_cache::{MAX_IMAGES, image_cache}; /// Width of the changed-files column. const TREE_WIDTH: f32 = 260.; @@ -91,10 +92,6 @@ impl PullRequestDetailView { window: &mut Window, cx: &mut Context, ) -> Self { - // PR author avatars stay in the shared cache until the panel - // closes; free them then. - crate::image_cache::clear_on_release(&cx.entity(), window, cx); - let tree_state = cx.new(|cx| TreeState::new(cx)); let comment_input = cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment…")); @@ -1160,6 +1157,7 @@ impl Focusable for PullRequestDetailView { impl Render for PullRequestDetailView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() + .image_cache(image_cache("pull-request-detail", MAX_IMAGES)) .id("pull-request-detail") .size_full() .min_h_0() diff --git a/crates/workspace/src/views/repo_detail/pull_requests.rs b/crates/workspace/src/views/repo_detail/pull_requests.rs index 228747b..fc72b91 100644 --- a/crates/workspace/src/views/repo_detail/pull_requests.rs +++ b/crates/workspace/src/views/repo_detail/pull_requests.rs @@ -24,6 +24,7 @@ use utils::relative_time; use super::helpers::{placeholder, status_badge}; use super::pull_request_detail::PullRequestDetailView; +use crate::image_cache::{MAX_IMAGES, image_cache}; /// Height of one pull request row in the virtual list: same layout as an /// issue row (8px vertical padding (`py_2`) on top and bottom, a 32px title @@ -91,13 +92,9 @@ impl PullRequestsView { dock_area: WeakEntity, store: Entity, repo_name: SharedString, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) -> Self { - // PR author avatars stay in the shared cache until the panel - // closes; free them then. - crate::image_cache::clear_on_release(&cx.entity(), window, cx); - Self { focus_handle: cx.focus_handle(), dock_area, @@ -569,6 +566,7 @@ impl Render for PullRequestsView { v_flex() .size_full() + .image_cache(image_cache("pull-requests", MAX_IMAGES)) .child(self.render_header(cx)) .child( v_flex() diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 3282197..6ce8437 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -16,6 +16,7 @@ use signed_state::{ProfileStore, RepoListStore, Timestamp}; use utils::relative_time; use super::RepoDetailView; +use crate::image_cache::{MAX_IMAGES, image_cache}; const CARD_HEIGHT: f32 = 160.; @@ -201,6 +202,7 @@ impl Render for RepoListView { v_flex() .relative() + .image_cache(image_cache("repos", MAX_IMAGES)) .size_full() .child( h_flex() diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 94c0b3e..14e73cf 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -15,6 +15,7 @@ use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_ use signed_state::{Backend, BackendEvent, Profile, ProfileStore}; use super::RepoListView; +use crate::image_cache::{MAX_IMAGES, image_cache}; mod import_dialog; mod onboarding_dialog; @@ -209,6 +210,7 @@ impl Render for SidebarPanel { v_flex() .size_full() .justify_between() + .image_cache(image_cache("sidebar", MAX_IMAGES)) .bg(cx.theme().sidebar) .text_color(cx.theme().sidebar_foreground) .child( diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 050c40e..4c97a3b 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -4,6 +4,7 @@ use gpui::{Context, Entity, Render, Subscription, Window, div, px}; use gpui_component::{Root, StyledExt, Theme}; use signed_state::{Backend, BackendEvent}; +use crate::image_cache::{MAX_IMAGES, image_cache}; use crate::views::SidebarPanel; use crate::views::sidebar::passphrase_dialog; @@ -111,7 +112,7 @@ impl Render for Workspace { let notification_layer = Root::render_notification_layer(window, cx); div() - .image_cache(crate::image_cache::global(cx)) + .image_cache(image_cache("workspace", MAX_IMAGES)) .id("workspace") .v_flex() .size_full() diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 1f3e638..4ef5bf3 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -51,10 +51,6 @@ fn main() { // Sync the theme with the system appearance Theme::sync_system_appearance(None, cx); - // Install the shared image cache so avatars can be freed when - // their views close, instead of staying in memory forever. - workspace::image_cache::init(cx); - // Initialize backend and stores (connects relays, restores session) std::fs::create_dir_all(paths::nostr_dir()).ok(); signed_state::init(paths::nostr_dir(), cx);