update image cache

This commit is contained in:
2026-08-25 20:22:13 +07:00
parent e1eef3d107
commit c1cab60a0b
10 changed files with 107 additions and 164 deletions
+88 -138
View File
@@ -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<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
pub fn image_cache(id: impl Into<ElementId>, 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<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 {
pub struct AppImageCacheProvider {
id: ElementId,
max_items: usize,
/// Most recently used hashes first.
usage: VecDeque<u64>,
}
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<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.
impl AppImageCache {
pub fn new(max_items: usize, cx: &mut App) -> Entity<Self> {
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<Result<Arc<RenderImage>, 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<Result<std::sync::Arc<gpui::RenderImage>, 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::<ImageAssetLoader>::load(resource.clone(), cx);
let task = cx.background_executor().spawn(fut).shared();
let load_future = AssetLogger::<ImageAssetLoader>::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();
@@ -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>,
) -> 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(
@@ -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<DockArea>,
store: Entity<RepoStore>,
repo_name: SharedString,
window: &mut Window,
_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(),
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()
@@ -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>,
) -> 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))
@@ -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>,
) -> 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<Self>) -> impl IntoElement {
v_flex()
.image_cache(image_cache("pull-request-detail", MAX_IMAGES))
.id("pull-request-detail")
.size_full()
.min_h_0()
@@ -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<DockArea>,
store: Entity<RepoStore>,
repo_name: SharedString,
window: &mut Window,
_window: &mut Window,
cx: &mut Context<Self>,
) -> 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()
+2
View File
@@ -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()
@@ -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(
+2 -1
View File
@@ -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()