add image cache

This commit is contained in:
2026-08-17 20:27:31 +07:00
parent 1159252dda
commit 37d6c3ccd6
8 changed files with 232 additions and 13 deletions
+2
View File
@@ -17,3 +17,5 @@ gix.workspace = true
nostr.workspace = true
anyhow.workspace = true
futures.workspace = true
log.workspace = true
+189
View File
@@ -0,0 +1,189 @@
//! The shared image cache used by every `img` element in the app.
//!
//! Without an explicit cache, images (the profile pictures shown by
//! `Avatar`s) fall back to the window's asset cache and are retained for the
//! lifetime of the window: every avatar that was ever shown stays decoded in
//! memory. This module installs one bounded LRU cache that the app controls
//! instead:
//!
//! * The cache holds at most [`MAX_IMAGES`] entries; loading a new image
//! evicts the least recently used one. Entries remember the [`Resource`]
//! they were loaded from, so eviction drops the image from the sprite
//! atlas *and* removes the asset from the asset system — freeing the raw
//! fetched bytes alongside the decoded image.
//! * [`clear_on_release`] drops the whole cache when an image-heavy view
//! (`RepoDetailView`, `IssuesView`) is released, i.e. its panel closes.
//! * [`clear`] drops everything on demand from anywhere in the app.
//!
//! Images that get cleared are re-fetched and re-decoded the next time they
//! are rendered, so clearing trades a little bandwidth/CPU for memory.
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,
};
/// 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;
/// Global handle to the shared image cache, installed by [`init`].
struct SharedImageCache(Entity<LruImageCache>);
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<LruImageCache> {
let cache = LruImageCache::new(MAX_IMAGES, cx);
cx.set_global(SharedImageCache(cache.clone()));
cache
}
/// The shared image cache. Panics if [`init`] hasn't been called.
pub fn global(cx: &App) -> Entity<LruImageCache> {
cx.global::<SharedImageCache>().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<T: 'static>(view: &Entity<T>, 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 {
max_items: usize,
/// Most recently used hashes first.
usage: VecDeque<u64>,
cache: HashMap<u64, (ImageCacheItem, Resource)>,
}
impl LruImageCache {
/// Create a cache that holds at most `max_items` images. Cached images
/// are dropped from every window when the cache is released.
pub fn new(max_items: usize, cx: &mut App) -> Entity<Self> {
let max_items = max_items.max(1);
cx.new(|cx| {
cx.on_release(|this: &mut Self, cx| {
for (_, entry) in take(&mut this.cache) {
unload(entry, None, cx);
}
})
.detach();
Self {
max_items,
usage: 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 from the sprite atlas and remove its resource from the
/// asset system, so both the decoded image and the raw fetched bytes are
/// freed. `window` restricts the atlas removal to the current window; `None`
/// removes it from all windows.
fn unload(
(mut item, resource): (ImageCacheItem, Resource),
window: Option<&mut Window>,
cx: &mut App,
) {
if let Some(Ok(image)) = item.get() {
cx.drop_image(image, window);
}
ImageSource::Resource(resource).remove_asset(cx);
}
impl ImageCache for LruImageCache {
fn load(
&mut self,
resource: &Resource,
window: &mut Window,
cx: &mut App,
) -> Option<Result<Arc<RenderImage>, ImageCacheError>> {
debug_assert_eq!(self.usage.len(), self.cache.len());
debug_assert!(self.cache.len() <= self.max_items);
let hash = hash(resource);
if let Some((item, _)) = self.cache.get_mut(&hash) {
let current_ix = self
.usage
.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();
}
let fut = AssetLogger::<ImageAssetLoader>::load(resource.clone(), cx);
let task = cx.background_executor().spawn(fut).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);
}
self.cache.insert(
hash,
(ImageCacheItem::Loading(task.clone()), resource.clone()),
);
self.usage.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);
});
}
})
.detach();
None
}
}
+3 -2
View File
@@ -1,11 +1,12 @@
mod views;
mod workspace;
pub use views::{RepoListView, SidebarPanel};
pub use workspace::Workspace;
pub mod image_cache;
use gpui::{App, AppContext, Entity, Window};
use gpui_component::Root;
pub use views::{RepoListView, SidebarPanel};
pub use workspace::Workspace;
/// Build the root view tree. Requires `signed_state::init` and
/// `gpui_component::init` to have been called first.
@@ -24,9 +24,11 @@ use utils::relative_time;
use super::helpers::placeholder;
/// Height of one issue row in the virtual list: two stacked text lines
/// (14px title + 12px meta, ~1.4x line height each) plus a little padding.
const ISSUE_ROW_HEIGHT: f32 = 40.;
/// Height of one issue row in the virtual list: 12px padding on top and
/// bottom, a 14px title line and a 24px meta line (the small avatar is the
/// tallest item). Gpui's default line height is phi (~1.62x), so the title
/// line is ~22.7px; the row totals ~71px.
const ISSUE_ROW_HEIGHT: f32 = 71.;
/// Panel listing all issues of a repository (no filters). The list stays
/// live by reading the store during `render`.
@@ -45,7 +47,16 @@ pub struct IssuesView {
}
impl IssuesView {
pub fn new(store: Entity<RepoStore>, repo_name: SharedString, cx: &mut Context<Self>) -> Self {
pub fn new(
store: Entity<RepoStore>,
repo_name: SharedString,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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(),
store,
@@ -71,7 +82,9 @@ impl IssuesView {
.h(px(ISSUE_ROW_HEIGHT))
.w_full()
.gap_4()
.px_3()
.p_3()
.border_b_1()
.border_color(cx.theme().border)
.items_start()
.child(Self::render_status(status, cx))
.child(
@@ -93,7 +106,6 @@ impl IssuesView {
.child(
h_flex()
.gap_1()
.items_center()
.child(
Avatar::new()
.name(author.clone())
@@ -102,6 +114,7 @@ impl IssuesView {
)
.child(div().child(author)),
)
.child(SharedString::from("opened"))
.child(
div()
.text_color(cx.theme().muted_foreground)
@@ -166,9 +179,7 @@ impl Panel for IssuesView {
}
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.text_sm()
.child(SharedString::from(format!("{}/issues", self.repo_name)))
div().child(SharedString::from(format!("{}/issues", self.repo_name)))
}
}
@@ -142,6 +142,10 @@ impl RepoDetailView {
window: &mut Window,
cx: &mut Context<Self>,
) -> 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);
let store = cx.new(|cx| RepoStore::new(initial.addr(), cx));
let tree_state = cx.new(|cx| TreeState::new(cx));
@@ -655,10 +659,11 @@ impl RepoDetailView {
return;
};
let panel = cx.new(|cx| IssuesView::new(self.store.clone(), self.display_name(cx), cx));
let panel =
cx.new(|cx| IssuesView::new(self.store.clone(), self.display_name(cx), window, cx));
dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel(Arc::new(panel), DockPlacement::Bottom, None, window, cx);
dock_area.add_panel(Arc::new(panel), DockPlacement::Center, None, window, cx);
});
}
+5
View File
@@ -112,6 +112,11 @@ impl Render for Workspace {
let notification_layer = Root::render_notification_layer(window, cx);
div()
// All `img` elements below (avatars, …) load through the shared
// bounded LRU cache instead of the window-global asset cache,
// so images can be freed when views close or when the cache is
// full.
.image_cache(crate::image_cache::global(cx))
.id("workspace")
.v_flex()
.size_full()