Enable reload of images in image viewer (#20374)

Will Bradley and Bennet created

Closes #11529

Release Notes:

- Fixed an issue where the image preview would not update when the
underlying file changed

---------

Co-authored-by: Bennet <bennet@zed.dev>

Change summary

Cargo.lock                              |   2 
crates/copilot/src/copilot.rs           |   4 
crates/gpui/src/elements/img.rs         |   6 
crates/image_viewer/Cargo.toml          |   1 
crates/image_viewer/src/image_viewer.rs | 172 +++----
crates/language/src/buffer.rs           |   5 
crates/project/Cargo.toml               |   1 
crates/project/src/image_store.rs       | 584 +++++++++++++++++++++++++++
crates/project/src/project.rs           |  89 +++
crates/worktree/src/worktree.rs         |  73 +++
10 files changed, 834 insertions(+), 103 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5978,6 +5978,7 @@ dependencies = [
  "settings",
  "theme",
  "ui",
+ "util",
  "workspace",
 ]
 
@@ -9080,6 +9081,7 @@ dependencies = [
  "globset",
  "gpui",
  "http_client",
+ "image",
  "itertools 0.13.0",
  "language",
  "log",

crates/copilot/src/copilot.rs 🔗

@@ -1272,5 +1272,9 @@ mod tests {
         fn load(&self, _: &AppContext) -> Task<Result<String>> {
             unimplemented!()
         }
+
+        fn load_bytes(&self, _cx: &AppContext) -> Task<Result<Vec<u8>>> {
+            unimplemented!()
+        }
     }
 }

crates/gpui/src/elements/img.rs 🔗

@@ -92,6 +92,12 @@ impl From<Arc<RenderImage>> for ImageSource {
     }
 }
 
+impl From<Arc<Image>> for ImageSource {
+    fn from(value: Arc<Image>) -> Self {
+        Self::Image(value)
+    }
+}
+
 /// An image element.
 pub struct Img {
     interactivity: Interactivity,

crates/image_viewer/Cargo.toml 🔗

@@ -21,4 +21,5 @@ project.workspace = true
 settings.workspace = true
 theme.workspace = true
 ui.workspace = true
+util.workspace = true
 workspace.workspace = true

crates/image_viewer/src/image_viewer.rs 🔗

@@ -1,18 +1,19 @@
+use std::path::PathBuf;
+
 use anyhow::Context as _;
 use gpui::{
-    canvas, div, fill, img, opaque_grey, point, size, AnyElement, AppContext, Bounds, Context,
-    EventEmitter, FocusHandle, FocusableView, Img, InteractiveElement, IntoElement, Model,
-    ObjectFit, ParentElement, Render, Styled, Task, View, ViewContext, VisualContext, WeakView,
-    WindowContext,
+    canvas, div, fill, img, opaque_grey, point, size, AnyElement, AppContext, Bounds, EventEmitter,
+    FocusHandle, FocusableView, InteractiveElement, IntoElement, Model, ObjectFit, ParentElement,
+    Render, Styled, Task, View, ViewContext, VisualContext, WeakView, WindowContext,
 };
 use persistence::IMAGE_VIEWER;
 use theme::Theme;
 use ui::prelude::*;
 
 use file_icons::FileIcons;
-use project::{Project, ProjectEntryId, ProjectPath};
+use project::{image_store::ImageItemEvent, ImageItem, Project, ProjectPath};
 use settings::Settings;
-use std::{ffi::OsStr, path::PathBuf};
+use util::paths::PathExt;
 use workspace::{
     item::{BreadcrumbText, Item, ProjectItem, SerializableItem, TabContentParams},
     ItemId, ItemSettings, Pane, ToolbarItemLocation, Workspace, WorkspaceId,
@@ -20,86 +21,80 @@ use workspace::{
 
 const IMAGE_VIEWER_KIND: &str = "ImageView";
 
-pub struct ImageItem {
-    id: ProjectEntryId,
-    path: PathBuf,
-    project_path: ProjectPath,
+pub struct ImageView {
+    image_item: Model<ImageItem>,
     project: Model<Project>,
+    focus_handle: FocusHandle,
 }
 
-impl project::Item for ImageItem {
-    fn try_open(
-        project: &Model<Project>,
-        path: &ProjectPath,
-        cx: &mut AppContext,
-    ) -> Option<Task<gpui::Result<Model<Self>>>> {
-        let path = path.clone();
-        let project = project.clone();
-
-        let ext = path
-            .path
-            .extension()
-            .and_then(OsStr::to_str)
-            .map(str::to_lowercase)
-            .unwrap_or_default();
-        let ext = ext.as_str();
-
-        // Only open the item if it's a binary image (no SVGs, etc.)
-        // Since we do not have a way to toggle to an editor
-        if Img::extensions().contains(&ext) && !ext.contains("svg") {
-            Some(cx.spawn(|mut cx| async move {
-                let abs_path = project
-                    .read_with(&cx, |project, cx| project.absolute_path(&path, cx))?
-                    .ok_or_else(|| anyhow::anyhow!("Failed to find the absolute path"))?;
-
-                let id = project
-                    .update(&mut cx, |project, cx| project.entry_for_path(&path, cx))?
-                    .context("Entry not found")?
-                    .id;
-
-                cx.new_model(|_| ImageItem {
-                    project,
-                    path: abs_path,
-                    project_path: path,
-                    id,
-                })
-            }))
-        } else {
-            None
+impl ImageView {
+    pub fn new(
+        image_item: Model<ImageItem>,
+        project: Model<Project>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        cx.subscribe(&image_item, Self::on_image_event).detach();
+        Self {
+            image_item,
+            project,
+            focus_handle: cx.focus_handle(),
         }
     }
 
-    fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
-        Some(self.id)
-    }
-
-    fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
-        Some(self.project_path.clone())
+    fn on_image_event(
+        &mut self,
+        _: Model<ImageItem>,
+        event: &ImageItemEvent,
+        cx: &mut ViewContext<Self>,
+    ) {
+        match event {
+            ImageItemEvent::FileHandleChanged | ImageItemEvent::Reloaded => {
+                cx.emit(ImageViewEvent::TitleChanged);
+                cx.notify();
+            }
+            ImageItemEvent::ReloadNeeded => {}
+        }
     }
 }
 
-pub struct ImageView {
-    image: Model<ImageItem>,
-    focus_handle: FocusHandle,
+pub enum ImageViewEvent {
+    TitleChanged,
 }
 
+impl EventEmitter<ImageViewEvent> for ImageView {}
+
 impl Item for ImageView {
-    type Event = ();
+    type Event = ImageViewEvent;
+
+    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
+        match event {
+            ImageViewEvent::TitleChanged => {
+                f(workspace::item::ItemEvent::UpdateTab);
+                f(workspace::item::ItemEvent::UpdateBreadcrumbs);
+            }
+        }
+    }
 
     fn for_each_project_item(
         &self,
         cx: &AppContext,
         f: &mut dyn FnMut(gpui::EntityId, &dyn project::Item),
     ) {
-        f(self.image.entity_id(), self.image.read(cx))
+        f(self.image_item.entity_id(), self.image_item.read(cx))
     }
 
     fn is_singleton(&self, _cx: &AppContext) -> bool {
         true
     }
 
+    fn tab_tooltip_text(&self, cx: &AppContext) -> Option<SharedString> {
+        let abs_path = self.image_item.read(cx).file.as_local()?.abs_path(cx);
+        let file_path = abs_path.compact().to_string_lossy().to_string();
+        Some(file_path.into())
+    }
+
     fn tab_content(&self, params: TabContentParams, cx: &WindowContext) -> AnyElement {
-        let path = &self.image.read(cx).path;
+        let path = self.image_item.read(cx).file.path();
         let title = path
             .file_name()
             .unwrap_or_else(|| path.as_os_str())
@@ -113,10 +108,10 @@ impl Item for ImageView {
     }
 
     fn tab_icon(&self, cx: &WindowContext) -> Option<Icon> {
-        let path = &self.image.read(cx).path;
+        let path = self.image_item.read(cx).path();
         ItemSettings::get_global(cx)
             .file_icons
-            .then(|| FileIcons::get_icon(path.as_path(), cx))
+            .then(|| FileIcons::get_icon(path, cx))
             .flatten()
             .map(Icon::from_path)
     }
@@ -126,7 +121,7 @@ impl Item for ImageView {
     }
 
     fn breadcrumbs(&self, _theme: &Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
-        let text = breadcrumbs_text_for_image(self.image.read(cx), cx);
+        let text = breadcrumbs_text_for_image(self.project.read(cx), self.image_item.read(cx), cx);
         Some(vec![BreadcrumbText {
             text,
             highlights: None,
@@ -143,22 +138,21 @@ impl Item for ImageView {
         Self: Sized,
     {
         Some(cx.new_view(|cx| Self {
-            image: self.image.clone(),
+            image_item: self.image_item.clone(),
+            project: self.project.clone(),
             focus_handle: cx.focus_handle(),
         }))
     }
 }
 
-fn breadcrumbs_text_for_image(image: &ImageItem, cx: &AppContext) -> String {
-    let path = &image.project_path.path;
-    let project = image.project.read(cx);
-
+fn breadcrumbs_text_for_image(project: &Project, image: &ImageItem, cx: &AppContext) -> String {
+    let path = image.path();
     if project.visible_worktrees(cx).count() <= 1 {
         return path.to_string_lossy().to_string();
     }
 
     project
-        .worktree_for_entry(image.id, cx)
+        .worktree_for_id(image.project_path(cx).worktree_id, cx)
         .map(|worktree| {
             PathBuf::from(worktree.read(cx).root_name())
                 .join(path)
@@ -198,26 +192,11 @@ impl SerializableItem for ImageView {
                 path: relative_path.into(),
             };
 
-            let id = project
-                .update(&mut cx, |project, cx| {
-                    project.entry_for_path(&project_path, cx)
-                })?
-                .context("No entry found")?
-                .id;
-
-            cx.update(|cx| {
-                let image = cx.new_model(|_| ImageItem {
-                    id,
-                    path: image_path,
-                    project_path,
-                    project,
-                });
-
-                Ok(cx.new_view(|cx| ImageView {
-                    image,
-                    focus_handle: cx.focus_handle(),
-                }))
-            })?
+            let image_item = project
+                .update(&mut cx, |project, cx| project.open_image(project_path, cx))?
+                .await?;
+
+            cx.update(|cx| Ok(cx.new_view(|cx| ImageView::new(image_item, project, cx))))?
         })
     }
 
@@ -237,9 +216,9 @@ impl SerializableItem for ImageView {
         cx: &mut ViewContext<Self>,
     ) -> Option<Task<gpui::Result<()>>> {
         let workspace_id = workspace.database_id()?;
+        let image_path = self.image_item.read(cx).file.as_local()?.abs_path(cx);
 
         Some(cx.background_executor().spawn({
-            let image_path = self.image.read(cx).path.clone();
             async move {
                 IMAGE_VIEWER
                     .save_image_path(item_id, workspace_id, image_path)
@@ -262,7 +241,7 @@ impl FocusableView for ImageView {
 
 impl Render for ImageView {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
-        let image_path = self.image.read(cx).path.clone();
+        let image = self.image_item.read(cx).image.clone();
         let checkered_background = |bounds: Bounds<Pixels>, _, cx: &mut WindowContext| {
             let square_size = 32.0;
 
@@ -319,7 +298,7 @@ impl Render for ImageView {
                     // TODO: In browser based Tailwind & Flex this would be h-screen and we'd use w-full
                     .h_full()
                     .child(
-                        img(image_path)
+                        img(image)
                             .object_fit(ObjectFit::ScaleDown)
                             .max_w_full()
                             .max_h_full(),
@@ -332,17 +311,14 @@ impl ProjectItem for ImageView {
     type Item = ImageItem;
 
     fn for_project_item(
-        _project: Model<Project>,
+        project: Model<Project>,
         item: Model<Self::Item>,
         cx: &mut ViewContext<Self>,
     ) -> Self
     where
         Self: Sized,
     {
-        Self {
-            image: item,
-            focus_handle: cx.focus_handle(),
-        }
+        Self::new(item, project, cx)
     }
 }
 

crates/language/src/buffer.rs 🔗

@@ -413,9 +413,12 @@ pub trait LocalFile: File {
     /// Returns the absolute path of this file
     fn abs_path(&self, cx: &AppContext) -> PathBuf;
 
-    /// Loads the file's contents from disk.
+    /// Loads the file contents from disk and returns them as a UTF-8 encoded string.
     fn load(&self, cx: &AppContext) -> Task<Result<String>>;
 
+    /// Loads the file's contents from disk.
+    fn load_bytes(&self, cx: &AppContext) -> Task<Result<Vec<u8>>>;
+
     /// Returns true if the file should not be shared with collaborators.
     fn is_private(&self, _: &AppContext) -> bool {
         false

crates/project/Cargo.toml 🔗

@@ -42,6 +42,7 @@ language.workspace = true
 log.workspace = true
 lsp.workspace = true
 node_runtime.workspace = true
+image.workspace = true
 parking_lot.workspace = true
 pathdiff.workspace = true
 paths.workspace = true

crates/project/src/image_store.rs 🔗

@@ -0,0 +1,584 @@
+use crate::worktree_store::{WorktreeStore, WorktreeStoreEvent};
+use crate::{Project, ProjectEntryId, ProjectPath};
+use anyhow::{Context as _, Result};
+use collections::{HashMap, HashSet};
+use futures::channel::oneshot;
+use gpui::{
+    hash, prelude::*, AppContext, EventEmitter, Img, Model, ModelContext, Subscription, Task,
+    WeakModel,
+};
+use language::File;
+use rpc::AnyProtoClient;
+use std::ffi::OsStr;
+use std::num::NonZeroU64;
+use std::path::Path;
+use std::sync::Arc;
+use util::ResultExt;
+use worktree::{LoadedBinaryFile, PathChange, Worktree};
+
+#[derive(Clone, Copy, Debug, Hash, PartialEq, PartialOrd, Ord, Eq)]
+pub struct ImageId(NonZeroU64);
+
+impl std::fmt::Display for ImageId {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", self.0)
+    }
+}
+
+impl From<NonZeroU64> for ImageId {
+    fn from(id: NonZeroU64) -> Self {
+        ImageId(id)
+    }
+}
+
+pub enum ImageItemEvent {
+    ReloadNeeded,
+    Reloaded,
+    FileHandleChanged,
+}
+
+impl EventEmitter<ImageItemEvent> for ImageItem {}
+
+pub enum ImageStoreEvent {
+    ImageAdded(Model<ImageItem>),
+}
+
+impl EventEmitter<ImageStoreEvent> for ImageStore {}
+
+pub struct ImageItem {
+    pub id: ImageId,
+    pub file: Arc<dyn File>,
+    pub image: Arc<gpui::Image>,
+    reload_task: Option<Task<()>>,
+}
+
+impl ImageItem {
+    pub fn project_path(&self, cx: &AppContext) -> ProjectPath {
+        ProjectPath {
+            worktree_id: self.file.worktree_id(cx),
+            path: self.file.path().clone(),
+        }
+    }
+
+    pub fn path(&self) -> &Arc<Path> {
+        self.file.path()
+    }
+
+    fn file_updated(&mut self, new_file: Arc<dyn File>, cx: &mut ModelContext<Self>) {
+        let mut file_changed = false;
+
+        let old_file = self.file.as_ref();
+        if new_file.path() != old_file.path() {
+            file_changed = true;
+        }
+
+        if !new_file.is_deleted() {
+            let new_mtime = new_file.mtime();
+            if new_mtime != old_file.mtime() {
+                file_changed = true;
+                cx.emit(ImageItemEvent::ReloadNeeded);
+            }
+        }
+
+        self.file = new_file;
+        if file_changed {
+            cx.emit(ImageItemEvent::FileHandleChanged);
+            cx.notify();
+        }
+    }
+
+    fn reload(&mut self, cx: &mut ModelContext<Self>) -> Option<oneshot::Receiver<()>> {
+        let local_file = self.file.as_local()?;
+        let (tx, rx) = futures::channel::oneshot::channel();
+
+        let content = local_file.load_bytes(cx);
+        self.reload_task = Some(cx.spawn(|this, mut cx| async move {
+            if let Some(image) = content
+                .await
+                .context("Failed to load image content")
+                .and_then(create_gpui_image)
+                .log_err()
+            {
+                this.update(&mut cx, |this, cx| {
+                    this.image = image;
+                    cx.emit(ImageItemEvent::Reloaded);
+                })
+                .log_err();
+            }
+            _ = tx.send(());
+        }));
+        Some(rx)
+    }
+}
+
+impl crate::Item for ImageItem {
+    fn try_open(
+        project: &Model<Project>,
+        path: &ProjectPath,
+        cx: &mut AppContext,
+    ) -> Option<Task<gpui::Result<Model<Self>>>> {
+        let path = path.clone();
+        let project = project.clone();
+
+        let ext = path
+            .path
+            .extension()
+            .and_then(OsStr::to_str)
+            .map(str::to_lowercase)
+            .unwrap_or_default();
+        let ext = ext.as_str();
+
+        // Only open the item if it's a binary image (no SVGs, etc.)
+        // Since we do not have a way to toggle to an editor
+        if Img::extensions().contains(&ext) && !ext.contains("svg") {
+            Some(cx.spawn(|mut cx| async move {
+                project
+                    .update(&mut cx, |project, cx| project.open_image(path, cx))?
+                    .await
+            }))
+        } else {
+            None
+        }
+    }
+
+    fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
+        worktree::File::from_dyn(Some(&self.file))?.entry_id
+    }
+
+    fn project_path(&self, cx: &AppContext) -> Option<ProjectPath> {
+        Some(self.project_path(cx).clone())
+    }
+}
+
+trait ImageStoreImpl {
+    fn open_image(
+        &self,
+        path: Arc<Path>,
+        worktree: Model<Worktree>,
+        cx: &mut ModelContext<ImageStore>,
+    ) -> Task<Result<Model<ImageItem>>>;
+
+    fn reload_images(
+        &self,
+        images: HashSet<Model<ImageItem>>,
+        cx: &mut ModelContext<ImageStore>,
+    ) -> Task<Result<()>>;
+
+    fn as_local(&self) -> Option<Model<LocalImageStore>>;
+}
+
+struct RemoteImageStore {}
+
+struct LocalImageStore {
+    local_image_ids_by_path: HashMap<ProjectPath, ImageId>,
+    local_image_ids_by_entry_id: HashMap<ProjectEntryId, ImageId>,
+    image_store: WeakModel<ImageStore>,
+    _subscription: Subscription,
+}
+
+pub struct ImageStore {
+    state: Box<dyn ImageStoreImpl>,
+    opened_images: HashMap<ImageId, WeakModel<ImageItem>>,
+    worktree_store: Model<WorktreeStore>,
+}
+
+impl ImageStore {
+    pub fn local(worktree_store: Model<WorktreeStore>, cx: &mut ModelContext<Self>) -> Self {
+        let this = cx.weak_model();
+        Self {
+            state: Box::new(cx.new_model(|cx| {
+                let subscription = cx.subscribe(
+                    &worktree_store,
+                    |this: &mut LocalImageStore, _, event, cx| {
+                        if let WorktreeStoreEvent::WorktreeAdded(worktree) = event {
+                            this.subscribe_to_worktree(worktree, cx);
+                        }
+                    },
+                );
+
+                LocalImageStore {
+                    local_image_ids_by_path: Default::default(),
+                    local_image_ids_by_entry_id: Default::default(),
+                    image_store: this,
+                    _subscription: subscription,
+                }
+            })),
+            opened_images: Default::default(),
+            worktree_store,
+        }
+    }
+
+    pub fn remote(
+        worktree_store: Model<WorktreeStore>,
+        _upstream_client: AnyProtoClient,
+        _remote_id: u64,
+        cx: &mut ModelContext<Self>,
+    ) -> Self {
+        Self {
+            state: Box::new(cx.new_model(|_| RemoteImageStore {})),
+            opened_images: Default::default(),
+            worktree_store,
+        }
+    }
+
+    pub fn images(&self) -> impl '_ + Iterator<Item = Model<ImageItem>> {
+        self.opened_images
+            .values()
+            .filter_map(|image| image.upgrade())
+    }
+
+    pub fn get(&self, image_id: ImageId) -> Option<Model<ImageItem>> {
+        self.opened_images
+            .get(&image_id)
+            .and_then(|image| image.upgrade())
+    }
+
+    pub fn get_by_path(&self, path: &ProjectPath, cx: &AppContext) -> Option<Model<ImageItem>> {
+        self.images()
+            .find(|image| &image.read(cx).project_path(cx) == path)
+    }
+
+    pub fn open_image(
+        &mut self,
+        project_path: ProjectPath,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<Model<ImageItem>>> {
+        let existing_image = self.get_by_path(&project_path, cx);
+        if let Some(existing_image) = existing_image {
+            return Task::ready(Ok(existing_image));
+        }
+
+        let Some(worktree) = self
+            .worktree_store
+            .read(cx)
+            .worktree_for_id(project_path.worktree_id, cx)
+        else {
+            return Task::ready(Err(anyhow::anyhow!("no such worktree")));
+        };
+
+        self.state
+            .open_image(project_path.path.clone(), worktree, cx)
+    }
+
+    pub fn reload_images(
+        &self,
+        images: HashSet<Model<ImageItem>>,
+        cx: &mut ModelContext<ImageStore>,
+    ) -> Task<Result<()>> {
+        if images.is_empty() {
+            return Task::ready(Ok(()));
+        }
+
+        self.state.reload_images(images, cx)
+    }
+
+    fn add_image(
+        &mut self,
+        image: Model<ImageItem>,
+        cx: &mut ModelContext<ImageStore>,
+    ) -> Result<()> {
+        let image_id = image.read(cx).id;
+
+        self.opened_images.insert(image_id, image.downgrade());
+
+        cx.subscribe(&image, Self::on_image_event).detach();
+        cx.emit(ImageStoreEvent::ImageAdded(image));
+        Ok(())
+    }
+
+    fn on_image_event(
+        &mut self,
+        image: Model<ImageItem>,
+        event: &ImageItemEvent,
+        cx: &mut ModelContext<Self>,
+    ) {
+        match event {
+            ImageItemEvent::FileHandleChanged => {
+                if let Some(local) = self.state.as_local() {
+                    local.update(cx, |local, cx| {
+                        local.image_changed_file(image, cx);
+                    })
+                }
+            }
+            _ => {}
+        }
+    }
+}
+
+impl ImageStoreImpl for Model<LocalImageStore> {
+    fn open_image(
+        &self,
+        path: Arc<Path>,
+        worktree: Model<Worktree>,
+        cx: &mut ModelContext<ImageStore>,
+    ) -> Task<Result<Model<ImageItem>>> {
+        let this = self.clone();
+
+        let load_file = worktree.update(cx, |worktree, cx| {
+            worktree.load_binary_file(path.as_ref(), cx)
+        });
+        cx.spawn(move |image_store, mut cx| async move {
+            let LoadedBinaryFile { file, content } = load_file.await?;
+            let image = create_gpui_image(content)?;
+
+            let model = cx.new_model(|cx| ImageItem {
+                id: cx.entity_id().as_non_zero_u64().into(),
+                file: file.clone(),
+                image,
+                reload_task: None,
+            })?;
+
+            let image_id = cx.read_model(&model, |model, _| model.id)?;
+
+            this.update(&mut cx, |this, cx| {
+                image_store.update(cx, |image_store, cx| {
+                    image_store.add_image(model.clone(), cx)
+                })??;
+                this.local_image_ids_by_path.insert(
+                    ProjectPath {
+                        worktree_id: file.worktree_id(cx),
+                        path: file.path.clone(),
+                    },
+                    image_id,
+                );
+
+                if let Some(entry_id) = file.entry_id {
+                    this.local_image_ids_by_entry_id.insert(entry_id, image_id);
+                }
+
+                anyhow::Ok(())
+            })??;
+
+            Ok(model)
+        })
+    }
+
+    fn reload_images(
+        &self,
+        images: HashSet<Model<ImageItem>>,
+        cx: &mut ModelContext<ImageStore>,
+    ) -> Task<Result<()>> {
+        cx.spawn(move |_, mut cx| async move {
+            for image in images {
+                if let Some(rec) = image.update(&mut cx, |image, cx| image.reload(cx))? {
+                    rec.await?
+                }
+            }
+            Ok(())
+        })
+    }
+
+    fn as_local(&self) -> Option<Model<LocalImageStore>> {
+        Some(self.clone())
+    }
+}
+
+impl LocalImageStore {
+    fn subscribe_to_worktree(&mut self, worktree: &Model<Worktree>, cx: &mut ModelContext<Self>) {
+        cx.subscribe(worktree, |this, worktree, event, cx| {
+            if worktree.read(cx).is_local() {
+                match event {
+                    worktree::Event::UpdatedEntries(changes) => {
+                        this.local_worktree_entries_changed(&worktree, changes, cx);
+                    }
+                    _ => {}
+                }
+            }
+        })
+        .detach();
+    }
+
+    fn local_worktree_entries_changed(
+        &mut self,
+        worktree_handle: &Model<Worktree>,
+        changes: &[(Arc<Path>, ProjectEntryId, PathChange)],
+        cx: &mut ModelContext<Self>,
+    ) {
+        let snapshot = worktree_handle.read(cx).snapshot();
+        for (path, entry_id, _) in changes {
+            self.local_worktree_entry_changed(*entry_id, path, worktree_handle, &snapshot, cx);
+        }
+    }
+
+    fn local_worktree_entry_changed(
+        &mut self,
+        entry_id: ProjectEntryId,
+        path: &Arc<Path>,
+        worktree: &Model<worktree::Worktree>,
+        snapshot: &worktree::Snapshot,
+        cx: &mut ModelContext<Self>,
+    ) -> Option<()> {
+        let project_path = ProjectPath {
+            worktree_id: snapshot.id(),
+            path: path.clone(),
+        };
+        let image_id = match self.local_image_ids_by_entry_id.get(&entry_id) {
+            Some(&image_id) => image_id,
+            None => self.local_image_ids_by_path.get(&project_path).copied()?,
+        };
+
+        let image = self
+            .image_store
+            .update(cx, |image_store, _| {
+                if let Some(image) = image_store.get(image_id) {
+                    Some(image)
+                } else {
+                    image_store.opened_images.remove(&image_id);
+                    None
+                }
+            })
+            .ok()
+            .flatten();
+        let image = if let Some(image) = image {
+            image
+        } else {
+            self.local_image_ids_by_path.remove(&project_path);
+            self.local_image_ids_by_entry_id.remove(&entry_id);
+            return None;
+        };
+
+        image.update(cx, |image, cx| {
+            let Some(old_file) = worktree::File::from_dyn(Some(&image.file)) else {
+                return;
+            };
+            if old_file.worktree != *worktree {
+                return;
+            }
+
+            let new_file = if let Some(entry) = old_file
+                .entry_id
+                .and_then(|entry_id| snapshot.entry_for_id(entry_id))
+            {
+                worktree::File {
+                    is_local: true,
+                    entry_id: Some(entry.id),
+                    mtime: entry.mtime,
+                    path: entry.path.clone(),
+                    worktree: worktree.clone(),
+                    is_deleted: false,
+                    is_private: entry.is_private,
+                }
+            } else if let Some(entry) = snapshot.entry_for_path(old_file.path.as_ref()) {
+                worktree::File {
+                    is_local: true,
+                    entry_id: Some(entry.id),
+                    mtime: entry.mtime,
+                    path: entry.path.clone(),
+                    worktree: worktree.clone(),
+                    is_deleted: false,
+                    is_private: entry.is_private,
+                }
+            } else {
+                worktree::File {
+                    is_local: true,
+                    entry_id: old_file.entry_id,
+                    path: old_file.path.clone(),
+                    mtime: old_file.mtime,
+                    worktree: worktree.clone(),
+                    is_deleted: true,
+                    is_private: old_file.is_private,
+                }
+            };
+
+            if new_file == *old_file {
+                return;
+            }
+
+            if new_file.path != old_file.path {
+                self.local_image_ids_by_path.remove(&ProjectPath {
+                    path: old_file.path.clone(),
+                    worktree_id: old_file.worktree_id(cx),
+                });
+                self.local_image_ids_by_path.insert(
+                    ProjectPath {
+                        worktree_id: new_file.worktree_id(cx),
+                        path: new_file.path.clone(),
+                    },
+                    image_id,
+                );
+            }
+
+            if new_file.entry_id != old_file.entry_id {
+                if let Some(entry_id) = old_file.entry_id {
+                    self.local_image_ids_by_entry_id.remove(&entry_id);
+                }
+                if let Some(entry_id) = new_file.entry_id {
+                    self.local_image_ids_by_entry_id.insert(entry_id, image_id);
+                }
+            }
+
+            image.file_updated(Arc::new(new_file), cx);
+        });
+        None
+    }
+
+    fn image_changed_file(&mut self, image: Model<ImageItem>, cx: &mut AppContext) -> Option<()> {
+        let file = worktree::File::from_dyn(Some(&image.read(cx).file))?;
+
+        let image_id = image.read(cx).id;
+        if let Some(entry_id) = file.entry_id {
+            match self.local_image_ids_by_entry_id.get(&entry_id) {
+                Some(_) => {
+                    return None;
+                }
+                None => {
+                    self.local_image_ids_by_entry_id.insert(entry_id, image_id);
+                }
+            }
+        };
+        self.local_image_ids_by_path.insert(
+            ProjectPath {
+                worktree_id: file.worktree_id(cx),
+                path: file.path.clone(),
+            },
+            image_id,
+        );
+
+        Some(())
+    }
+}
+
+fn create_gpui_image(content: Vec<u8>) -> anyhow::Result<Arc<gpui::Image>> {
+    let format = image::guess_format(&content)?;
+
+    Ok(Arc::new(gpui::Image {
+        id: hash(&content),
+        format: match format {
+            image::ImageFormat::Png => gpui::ImageFormat::Png,
+            image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg,
+            image::ImageFormat::WebP => gpui::ImageFormat::Webp,
+            image::ImageFormat::Gif => gpui::ImageFormat::Gif,
+            image::ImageFormat::Bmp => gpui::ImageFormat::Bmp,
+            image::ImageFormat::Tiff => gpui::ImageFormat::Tiff,
+            _ => Err(anyhow::anyhow!("Image format not supported"))?,
+        },
+        bytes: content,
+    }))
+}
+
+impl ImageStoreImpl for Model<RemoteImageStore> {
+    fn open_image(
+        &self,
+        _path: Arc<Path>,
+        _worktree: Model<Worktree>,
+        _cx: &mut ModelContext<ImageStore>,
+    ) -> Task<Result<Model<ImageItem>>> {
+        Task::ready(Err(anyhow::anyhow!(
+            "Opening images from remote is not supported"
+        )))
+    }
+
+    fn reload_images(
+        &self,
+        _images: HashSet<Model<ImageItem>>,
+        _cx: &mut ModelContext<ImageStore>,
+    ) -> Task<Result<()>> {
+        Task::ready(Err(anyhow::anyhow!(
+            "Reloading images from remote is not supported"
+        )))
+    }
+
+    fn as_local(&self) -> Option<Model<LocalImageStore>> {
+        None
+    }
+}

crates/project/src/project.rs 🔗

@@ -2,6 +2,7 @@ pub mod buffer_store;
 mod color_extractor;
 pub mod connection_manager;
 pub mod debounced_delay;
+pub mod image_store;
 pub mod lsp_command;
 pub mod lsp_ext_command;
 pub mod lsp_store;
@@ -35,6 +36,8 @@ use futures::{
     future::try_join_all,
     StreamExt,
 };
+pub use image_store::{ImageItem, ImageStore};
+use image_store::{ImageItemEvent, ImageStoreEvent};
 
 use git::{blame::Blame, repository::GitRepository};
 use gpui::{
@@ -146,6 +149,7 @@ pub struct Project {
     client_subscriptions: Vec<client::Subscription>,
     worktree_store: Model<WorktreeStore>,
     buffer_store: Model<BufferStore>,
+    image_store: Model<ImageStore>,
     lsp_store: Model<LspStore>,
     _subscriptions: Vec<gpui::Subscription>,
     buffers_needing_diff: HashSet<WeakModel<Buffer>>,
@@ -205,10 +209,11 @@ enum BufferOrderedMessage {
 
 #[derive(Debug)]
 enum ProjectClientState {
+    /// Single-player mode.
     Local,
-    Shared {
-        remote_id: u64,
-    },
+    /// Multi-player mode but still a local project.
+    Shared { remote_id: u64 },
+    /// Multi-player mode but working on a remote project.
     Remote {
         sharing_has_stopped: bool,
         capability: Capability,
@@ -606,6 +611,10 @@ impl Project {
             cx.subscribe(&buffer_store, Self::on_buffer_store_event)
                 .detach();
 
+            let image_store = cx.new_model(|cx| ImageStore::local(worktree_store.clone(), cx));
+            cx.subscribe(&image_store, Self::on_image_store_event)
+                .detach();
+
             let prettier_store = cx.new_model(|cx| {
                 PrettierStore::new(
                     node.clone(),
@@ -666,6 +675,7 @@ impl Project {
                 collaborators: Default::default(),
                 worktree_store,
                 buffer_store,
+                image_store,
                 lsp_store,
                 join_project_response_message_id: 0,
                 client_state: ProjectClientState::Local,
@@ -729,6 +739,14 @@ impl Project {
                     cx,
                 )
             });
+            let image_store = cx.new_model(|cx| {
+                ImageStore::remote(
+                    worktree_store.clone(),
+                    ssh.read(cx).proto_client(),
+                    SSH_PROJECT_ID,
+                    cx,
+                )
+            });
             cx.subscribe(&buffer_store, Self::on_buffer_store_event)
                 .detach();
 
@@ -774,6 +792,7 @@ impl Project {
                 collaborators: Default::default(),
                 worktree_store,
                 buffer_store,
+                image_store,
                 lsp_store,
                 join_project_response_message_id: 0,
                 client_state: ProjectClientState::Local,
@@ -920,6 +939,9 @@ impl Project {
         let buffer_store = cx.new_model(|cx| {
             BufferStore::remote(worktree_store.clone(), client.clone().into(), remote_id, cx)
         })?;
+        let image_store = cx.new_model(|cx| {
+            ImageStore::remote(worktree_store.clone(), client.clone().into(), remote_id, cx)
+        })?;
 
         let lsp_store = cx.new_model(|cx| {
             let mut lsp_store = LspStore::new_remote(
@@ -982,6 +1004,7 @@ impl Project {
             let mut this = Self {
                 buffer_ordered_messages_tx: tx,
                 buffer_store: buffer_store.clone(),
+                image_store,
                 worktree_store: worktree_store.clone(),
                 lsp_store: lsp_store.clone(),
                 active_entry: None,
@@ -1783,7 +1806,7 @@ impl Project {
         path: impl Into<ProjectPath>,
         cx: &mut ModelContext<Self>,
     ) -> Task<Result<Model<Buffer>>> {
-        if (self.is_via_collab() || self.is_via_ssh()) && self.is_disconnected(cx) {
+        if self.is_disconnected(cx) {
             return Task::ready(Err(anyhow!(ErrorCode::Disconnected)));
         }
 
@@ -1879,6 +1902,20 @@ impl Project {
         Ok(())
     }
 
+    pub fn open_image(
+        &mut self,
+        path: impl Into<ProjectPath>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<Model<ImageItem>>> {
+        if self.is_disconnected(cx) {
+            return Task::ready(Err(anyhow!(ErrorCode::Disconnected)));
+        }
+
+        self.image_store.update(cx, |image_store, cx| {
+            image_store.open_image(path.into(), cx)
+        })
+    }
+
     async fn send_buffer_ordered_messages(
         this: WeakModel<Self>,
         rx: UnboundedReceiver<BufferOrderedMessage>,
@@ -2013,6 +2050,22 @@ impl Project {
         }
     }
 
+    fn on_image_store_event(
+        &mut self,
+        _: Model<ImageStore>,
+        event: &ImageStoreEvent,
+        cx: &mut ModelContext<Self>,
+    ) {
+        match event {
+            ImageStoreEvent::ImageAdded(image) => {
+                cx.subscribe(image, |this, image, event, cx| {
+                    this.on_image_event(image, event, cx);
+                })
+                .detach();
+            }
+        }
+    }
+
     fn on_lsp_store_event(
         &mut self,
         _: Model<LspStore>,
@@ -2253,6 +2306,25 @@ impl Project {
         None
     }
 
+    fn on_image_event(
+        &mut self,
+        image: Model<ImageItem>,
+        event: &ImageItemEvent,
+        cx: &mut ModelContext<Self>,
+    ) -> Option<()> {
+        match event {
+            ImageItemEvent::ReloadNeeded => {
+                if !self.is_via_collab() {
+                    self.reload_images([image.clone()].into_iter().collect(), cx)
+                        .detach_and_log_err(cx);
+                }
+            }
+            _ => {}
+        }
+
+        None
+    }
+
     fn request_buffer_diff_recalculation(
         &mut self,
         buffer: &Model<Buffer>,
@@ -2466,6 +2538,15 @@ impl Project {
         })
     }
 
+    pub fn reload_images(
+        &self,
+        images: HashSet<Model<ImageItem>>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        self.image_store
+            .update(cx, |image_store, cx| image_store.reload_images(images, cx))
+    }
+
     pub fn format(
         &mut self,
         buffers: HashSet<Model<Buffer>>,

crates/worktree/src/worktree.rs 🔗

@@ -103,6 +103,11 @@ pub struct LoadedFile {
     pub diff_base: Option<String>,
 }
 
+pub struct LoadedBinaryFile {
+    pub file: Arc<File>,
+    pub content: Vec<u8>,
+}
+
 pub struct LocalWorktree {
     snapshot: LocalSnapshot,
     scan_requests_tx: channel::Sender<ScanRequest>,
@@ -685,6 +690,19 @@ impl Worktree {
         }
     }
 
+    pub fn load_binary_file(
+        &self,
+        path: &Path,
+        cx: &ModelContext<Worktree>,
+    ) -> Task<Result<LoadedBinaryFile>> {
+        match self {
+            Worktree::Local(this) => this.load_binary_file(path, cx),
+            Worktree::Remote(_) => {
+                Task::ready(Err(anyhow!("remote worktrees can't yet load binary files")))
+            }
+        }
+    }
+
     pub fn write_file(
         &self,
         path: &Path,
@@ -1260,6 +1278,53 @@ impl LocalWorktree {
         self.git_repositories.get(&repo.work_directory.0)
     }
 
+    fn load_binary_file(
+        &self,
+        path: &Path,
+        cx: &ModelContext<Worktree>,
+    ) -> Task<Result<LoadedBinaryFile>> {
+        let path = Arc::from(path);
+        let abs_path = self.absolutize(&path);
+        let fs = self.fs.clone();
+        let entry = self.refresh_entry(path.clone(), None, cx);
+        let is_private = self.is_path_private(path.as_ref());
+
+        let worktree = cx.weak_model();
+        cx.background_executor().spawn(async move {
+            let abs_path = abs_path?;
+            let content = fs.load_bytes(&abs_path).await?;
+
+            let worktree = worktree
+                .upgrade()
+                .ok_or_else(|| anyhow!("worktree was dropped"))?;
+            let file = match entry.await? {
+                Some(entry) => File::for_entry(entry, worktree),
+                None => {
+                    let metadata = fs
+                        .metadata(&abs_path)
+                        .await
+                        .with_context(|| {
+                            format!("Loading metadata for excluded file {abs_path:?}")
+                        })?
+                        .with_context(|| {
+                            format!("Excluded file {abs_path:?} got removed during loading")
+                        })?;
+                    Arc::new(File {
+                        entry_id: None,
+                        worktree,
+                        path,
+                        mtime: Some(metadata.mtime),
+                        is_local: true,
+                        is_deleted: false,
+                        is_private,
+                    })
+                }
+            };
+
+            Ok(LoadedBinaryFile { file, content })
+        })
+    }
+
     fn load_file(&self, path: &Path, cx: &ModelContext<Worktree>) -> Task<Result<LoadedFile>> {
         let path = Arc::from(path);
         let abs_path = self.absolutize(&path);
@@ -3213,6 +3278,14 @@ impl language::LocalFile for File {
         cx.background_executor()
             .spawn(async move { fs.load(&abs_path?).await })
     }
+
+    fn load_bytes(&self, cx: &AppContext) -> Task<Result<Vec<u8>>> {
+        let worktree = self.worktree.read(cx).as_local().unwrap();
+        let abs_path = worktree.absolutize(&self.path);
+        let fs = worktree.fs.clone();
+        cx.background_executor()
+            .spawn(async move { fs.load_bytes(&abs_path?).await })
+    }
 }
 
 impl File {