diff --git a/Cargo.lock b/Cargo.lock index 315a19b..764ce2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10805,9 +10805,11 @@ version = "1.0.0" dependencies = [ "anyhow", "assets", + "futures", "gix", "gpui", "gpui-component", + "log", "nostr", "signed_core", "signed_git", diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 933f140..9ddc66f 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -17,3 +17,5 @@ gix.workspace = true nostr.workspace = true anyhow.workspace = true +futures.workspace = true +log.workspace = true diff --git a/crates/workspace/src/image_cache.rs b/crates/workspace/src/image_cache.rs new file mode 100644 index 0000000..ef093b8 --- /dev/null +++ b/crates/workspace/src/image_cache.rs @@ -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); + +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 +} + +/// 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 { + max_items: usize, + /// Most recently used hashes first. + usage: 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. + pub fn new(max_items: usize, cx: &mut App) -> Entity { + 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, 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::::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 + } +} diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 7ee8808..04a39f9 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -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. diff --git a/crates/workspace/src/views/repo_detail/issues.rs b/crates/workspace/src/views/repo_detail/issues.rs index 0d115f4..f1cf180 100644 --- a/crates/workspace/src/views/repo_detail/issues.rs +++ b/crates/workspace/src/views/repo_detail/issues.rs @@ -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, repo_name: SharedString, cx: &mut Context) -> Self { + pub fn new( + store: Entity, + repo_name: SharedString, + 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(), 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) -> impl IntoElement { - div() - .text_sm() - .child(SharedString::from(format!("{}/issues", self.repo_name))) + div().child(SharedString::from(format!("{}/issues", self.repo_name))) } } diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index b5c11a4..7f252aa 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -142,6 +142,10 @@ 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); + 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); }); } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 5bca10b..97fbc5b 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -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() diff --git a/desktop/src/main.rs b/desktop/src/main.rs index d7f1aae..10dd8e7 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -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);