add image cache
This commit is contained in:
Generated
+2
@@ -10805,9 +10805,11 @@ version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assets",
|
||||
"futures",
|
||||
"gix",
|
||||
"gpui",
|
||||
"gpui-component",
|
||||
"log",
|
||||
"nostr",
|
||||
"signed_core",
|
||||
"signed_git",
|
||||
|
||||
@@ -17,3 +17,5 @@ gix.workspace = true
|
||||
nostr.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -22,6 +22,10 @@ fn main() {
|
||||
// Initialize theme
|
||||
theme::init(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);
|
||||
|
||||
Reference in New Issue
Block a user