Enable image support in remote projects (#39158)

Cave Bats Of Ware and Julia Ryan created

Adds support for opening and displaying images in remote projects. The
server streams image data to the client in chunks, where the client then
reconstructs the image and displays it. This change includes:

- Adding `image` crate as a dependency for remote_server
- Implementing `ImageStore` for remote access
- Creating proto definitions for image-related messages
- Adding handlers for creating images for peers
- Computing image metadata from bytes instead of reading from disk for
remote images

Closes #20430
Closes #39104
Closes #40445

Release Notes:

- Added support for image preview in remote sessions.
- Fixed #39104

<img width="982" height="551" alt="image"
src="https://github.com/user-attachments/assets/575428a3-9144-4c1f-b76f-952019ea14cc"
/>
<img width="978" height="547" alt="image"
src="https://github.com/user-attachments/assets/fb58243a-4856-4e73-bb30-8d5e188b3ac9"
/>

---------

Co-authored-by: Julia Ryan <juliaryan3.14@gmail.com>

Change summary

Cargo.lock                                   |   1 
crates/collab/src/rpc.rs                     |  22 +
crates/project/src/image_store.rs            | 312 ++++++++++++++++++---
crates/project/src/project.rs                |  36 ++
crates/proto/proto/image.proto               |  36 ++
crates/proto/proto/zed.proto                 |   7 
crates/proto/src/proto.rs                    |   6 
crates/remote_server/Cargo.toml              |   1 
crates/remote_server/src/headless_project.rs |  74 +++++
9 files changed, 433 insertions(+), 62 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -13969,6 +13969,7 @@ dependencies = [
  "gpui",
  "gpui_tokio",
  "http_client",
+ "image",
  "json_schema_store",
  "language",
  "language_extension",

crates/collab/src/rpc.rs 🔗

@@ -346,6 +346,7 @@ impl Server {
             .add_request_handler(forward_read_only_project_request::<proto::ResolveInlayHint>)
             .add_request_handler(forward_read_only_project_request::<proto::GetColorPresentation>)
             .add_request_handler(forward_read_only_project_request::<proto::OpenBufferByPath>)
+            .add_request_handler(forward_read_only_project_request::<proto::OpenImageByPath>)
             .add_request_handler(forward_read_only_project_request::<proto::GitGetBranches>)
             .add_request_handler(forward_read_only_project_request::<proto::GetDefaultBranch>)
             .add_request_handler(forward_read_only_project_request::<proto::OpenUnstagedDiff>)
@@ -395,6 +396,7 @@ impl Server {
             .add_request_handler(forward_mutating_project_request::<proto::StopLanguageServers>)
             .add_request_handler(forward_mutating_project_request::<proto::LinkedEditingRange>)
             .add_message_handler(create_buffer_for_peer)
+            .add_message_handler(create_image_for_peer)
             .add_request_handler(update_buffer)
             .add_message_handler(broadcast_project_message_from_host::<proto::RefreshInlayHints>)
             .add_message_handler(broadcast_project_message_from_host::<proto::RefreshCodeLens>)
@@ -2389,6 +2391,26 @@ async fn create_buffer_for_peer(
     Ok(())
 }
 
+/// Notify other participants that a new image has been created
+async fn create_image_for_peer(
+    request: proto::CreateImageForPeer,
+    session: MessageContext,
+) -> Result<()> {
+    session
+        .db()
+        .await
+        .check_user_is_project_host(
+            ProjectId::from_proto(request.project_id),
+            session.connection_id,
+        )
+        .await?;
+    let peer_id = request.peer_id.context("invalid peer id")?;
+    session
+        .peer
+        .forward_send(session.connection_id, peer_id.into(), request)?;
+    Ok(())
+}
+
 /// Notify other participants that a buffer has been updated. This is
 /// allowed for guests as long as the update is limited to selections.
 async fn update_buffer(

crates/project/src/image_store.rs 🔗

@@ -11,16 +11,22 @@ use gpui::{
 pub use image::ImageFormat;
 use image::{ExtendedColorType, GenericImageView, ImageReader};
 use language::{DiskState, File};
-use rpc::{AnyProtoClient, ErrorExt as _};
+use rpc::{AnyProtoClient, ErrorExt as _, TypedEnvelope, proto};
 use std::num::NonZeroU64;
 use std::path::PathBuf;
 use std::sync::Arc;
 use util::{ResultExt, rel_path::RelPath};
-use worktree::{LoadedBinaryFile, PathChange, Worktree};
+use worktree::{LoadedBinaryFile, PathChange, Worktree, WorktreeId};
 
 #[derive(Clone, Copy, Debug, Hash, PartialEq, PartialOrd, Ord, Eq)]
 pub struct ImageId(NonZeroU64);
 
+impl ImageId {
+    pub fn to_proto(&self) -> u64 {
+        self.0.get()
+    }
+}
+
 impl std::fmt::Display for ImageId {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         write!(f, "{}", self.0)
@@ -102,6 +108,24 @@ pub struct ImageItem {
 }
 
 impl ImageItem {
+    fn compute_metadata_from_bytes(image_bytes: &[u8]) -> Result<ImageMetadata> {
+        let image_format = image::guess_format(image_bytes)?;
+
+        let mut image_reader = ImageReader::new(std::io::Cursor::new(image_bytes));
+        image_reader.set_format(image_format);
+        let image = image_reader.decode()?;
+
+        let (width, height) = image.dimensions();
+
+        Ok(ImageMetadata {
+            width,
+            height,
+            file_size: image_bytes.len() as u64,
+            format: image_format,
+            colors: ImageColorInfo::from_color_type(image.color()),
+        })
+    }
+
     pub async fn load_image_metadata(
         image: Entity<ImageItem>,
         project: Entity<Project>,
@@ -117,25 +141,7 @@ impl ImageItem {
         })??;
 
         let image_bytes = fs.load_bytes(&image_path).await?;
-        let image_format = image::guess_format(&image_bytes)?;
-
-        let mut image_reader = ImageReader::new(std::io::Cursor::new(image_bytes));
-        image_reader.set_format(image_format);
-        let image = image_reader.decode()?;
-
-        let (width, height) = image.dimensions();
-        let file_metadata = fs
-            .metadata(image_path.as_path())
-            .await?
-            .context("failed to load image metadata")?;
-
-        Ok(ImageMetadata {
-            width,
-            height,
-            file_size: file_metadata.len,
-            format: image_format,
-            colors: ImageColorInfo::from_color_type(image.color()),
-        })
+        Self::compute_metadata_from_bytes(&image_bytes)
     }
 
     pub fn project_path(&self, cx: &App) -> ProjectPath {
@@ -265,9 +271,23 @@ trait ImageStoreImpl {
     ) -> Task<Result<()>>;
 
     fn as_local(&self) -> Option<Entity<LocalImageStore>>;
+    fn as_remote(&self) -> Option<Entity<RemoteImageStore>>;
 }
 
-struct RemoteImageStore {}
+struct RemoteImageStore {
+    upstream_client: AnyProtoClient,
+    project_id: u64,
+    loading_remote_images_by_id: HashMap<ImageId, LoadingRemoteImage>,
+    remote_image_listeners:
+        HashMap<ImageId, Vec<oneshot::Sender<anyhow::Result<Entity<ImageItem>>>>>,
+    loaded_images: HashMap<ImageId, Entity<ImageItem>>,
+}
+
+struct LoadingRemoteImage {
+    state: proto::ImageState,
+    chunks: Vec<Vec<u8>>,
+    received_size: u64,
+}
 
 struct LocalImageStore {
     local_image_ids_by_path: HashMap<ProjectPath, ImageId>,
@@ -316,12 +336,18 @@ impl ImageStore {
 
     pub fn remote(
         worktree_store: Entity<WorktreeStore>,
-        _upstream_client: AnyProtoClient,
-        _remote_id: u64,
+        upstream_client: AnyProtoClient,
+        project_id: u64,
         cx: &mut Context<Self>,
     ) -> Self {
         Self {
-            state: Box::new(cx.new(|_| RemoteImageStore {})),
+            state: Box::new(cx.new(|_| RemoteImageStore {
+                upstream_client,
+                project_id,
+                loading_remote_images_by_id: Default::default(),
+                remote_image_listeners: Default::default(),
+                loaded_images: Default::default(),
+            })),
             opened_images: Default::default(),
             loading_images_by_path: Default::default(),
             worktree_store,
@@ -429,9 +455,7 @@ impl ImageStore {
 
     fn add_image(&mut self, image: Entity<ImageItem>, cx: &mut Context<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(())
@@ -451,6 +475,135 @@ impl ImageStore {
             })
         }
     }
+
+    pub fn handle_create_image_for_peer(
+        &mut self,
+        envelope: TypedEnvelope<proto::CreateImageForPeer>,
+        cx: &mut Context<Self>,
+    ) -> Result<()> {
+        if let Some(remote) = self.state.as_remote() {
+            let worktree_store = self.worktree_store.clone();
+            let image = remote.update(cx, |remote, cx| {
+                remote.handle_create_image_for_peer(envelope, &worktree_store, cx)
+            })?;
+            if let Some(image) = image {
+                remote.update(cx, |this, cx| {
+                    let image = image.clone();
+                    let image_id = image.read(cx).id;
+                    this.loaded_images.insert(image_id, image)
+                });
+
+                self.add_image(image, cx)?;
+            }
+        }
+
+        Ok(())
+    }
+}
+
+impl RemoteImageStore {
+    pub fn wait_for_remote_image(
+        &mut self,
+        id: ImageId,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<Entity<ImageItem>>> {
+        if let Some(image) = self.loaded_images.remove(&id) {
+            return Task::ready(Ok(image));
+        }
+
+        let (tx, rx) = oneshot::channel();
+        self.remote_image_listeners.entry(id).or_default().push(tx);
+
+        cx.spawn(async move |_this, cx| {
+            let result = cx.background_spawn(async move { rx.await? }).await;
+            result
+        })
+    }
+
+    pub fn handle_create_image_for_peer(
+        &mut self,
+        envelope: TypedEnvelope<proto::CreateImageForPeer>,
+        worktree_store: &Entity<WorktreeStore>,
+        cx: &mut Context<Self>,
+    ) -> Result<Option<Entity<ImageItem>>> {
+        use proto::create_image_for_peer::Variant;
+        match envelope.payload.variant {
+            Some(Variant::State(state)) => {
+                let image_id =
+                    ImageId::from(NonZeroU64::new(state.id).context("invalid image id")?);
+
+                self.loading_remote_images_by_id.insert(
+                    image_id,
+                    LoadingRemoteImage {
+                        state,
+                        chunks: Vec::new(),
+                        received_size: 0,
+                    },
+                );
+                Ok(None)
+            }
+            Some(Variant::Chunk(chunk)) => {
+                let image_id =
+                    ImageId::from(NonZeroU64::new(chunk.image_id).context("invalid image id")?);
+
+                let loading = self
+                    .loading_remote_images_by_id
+                    .get_mut(&image_id)
+                    .context("received chunk for unknown image")?;
+
+                loading.received_size += chunk.data.len() as u64;
+                loading.chunks.push(chunk.data);
+
+                if loading.received_size == loading.state.content_size {
+                    let loading = self.loading_remote_images_by_id.remove(&image_id).unwrap();
+
+                    let mut content = Vec::with_capacity(loading.received_size as usize);
+                    for chunk_data in loading.chunks {
+                        content.extend_from_slice(&chunk_data);
+                    }
+
+                    let image_metadata = ImageItem::compute_metadata_from_bytes(&content).log_err();
+                    let image = create_gpui_image(content)?;
+
+                    let proto_file = loading.state.file.context("missing file in image state")?;
+                    let worktree_id = WorktreeId::from_proto(proto_file.worktree_id);
+                    let worktree = worktree_store
+                        .read(cx)
+                        .worktree_for_id(worktree_id, cx)
+                        .context("worktree not found")?;
+
+                    let file = Arc::new(
+                        worktree::File::from_proto(proto_file, worktree, cx)
+                            .context("invalid file in image state")?,
+                    );
+
+                    let entity = cx.new(|_cx| ImageItem {
+                        id: image_id,
+                        file,
+                        image,
+                        image_metadata,
+                        reload_task: None,
+                    });
+
+                    if let Some(listeners) = self.remote_image_listeners.remove(&image_id) {
+                        for listener in listeners {
+                            listener.send(Ok(entity.clone())).ok();
+                        }
+                    }
+
+                    Ok(Some(entity))
+                } else {
+                    Ok(None)
+                }
+            }
+            None => {
+                log::warn!("Received CreateImageForPeer with no variant");
+                Ok(None)
+            }
+        }
+    }
+
+    // TODO: subscribe to worktree and update image contents or at least mark as dirty on file changes
 }
 
 impl ImageStoreImpl for Entity<LocalImageStore> {
@@ -520,6 +673,64 @@ impl ImageStoreImpl for Entity<LocalImageStore> {
     fn as_local(&self) -> Option<Entity<LocalImageStore>> {
         Some(self.clone())
     }
+
+    fn as_remote(&self) -> Option<Entity<RemoteImageStore>> {
+        None
+    }
+}
+
+impl ImageStoreImpl for Entity<RemoteImageStore> {
+    fn open_image(
+        &self,
+        path: Arc<RelPath>,
+        worktree: Entity<Worktree>,
+        cx: &mut Context<ImageStore>,
+    ) -> Task<Result<Entity<ImageItem>>> {
+        let worktree_id = worktree.read(cx).id().to_proto();
+        let (project_id, client) = {
+            let store = self.read(cx);
+            (store.project_id, store.upstream_client.clone())
+        };
+        let remote_store = self.clone();
+
+        cx.spawn(async move |_image_store, cx| {
+            let response = client
+                .request(rpc::proto::OpenImageByPath {
+                    project_id,
+                    worktree_id,
+                    path: path.to_proto(),
+                })
+                .await?;
+
+            let image_id = ImageId::from(
+                NonZeroU64::new(response.image_id).context("invalid image_id in response")?,
+            );
+
+            remote_store
+                .update(cx, |remote_store, cx| {
+                    remote_store.wait_for_remote_image(image_id, cx)
+                })?
+                .await
+        })
+    }
+
+    fn reload_images(
+        &self,
+        _images: HashSet<Entity<ImageItem>>,
+        _cx: &mut Context<ImageStore>,
+    ) -> Task<Result<()>> {
+        Task::ready(Err(anyhow::anyhow!(
+            "Reloading images from remote is not supported"
+        )))
+    }
+
+    fn as_local(&self) -> Option<Entity<LocalImageStore>> {
+        None
+    }
+
+    fn as_remote(&self) -> Option<Entity<RemoteImageStore>> {
+        Some(self.clone())
+    }
 }
 
 impl LocalImageStore {
@@ -694,33 +905,6 @@ fn create_gpui_image(content: Vec<u8>) -> anyhow::Result<Arc<gpui::Image>> {
     )))
 }
 
-impl ImageStoreImpl for Entity<RemoteImageStore> {
-    fn open_image(
-        &self,
-        _path: Arc<RelPath>,
-        _worktree: Entity<Worktree>,
-        _cx: &mut Context<ImageStore>,
-    ) -> Task<Result<Entity<ImageItem>>> {
-        Task::ready(Err(anyhow::anyhow!(
-            "Opening images from remote is not supported"
-        )))
-    }
-
-    fn reload_images(
-        &self,
-        _images: HashSet<Entity<ImageItem>>,
-        _cx: &mut Context<ImageStore>,
-    ) -> Task<Result<()>> {
-        Task::ready(Err(anyhow::anyhow!(
-            "Reloading images from remote is not supported"
-        )))
-    }
-
-    fn as_local(&self) -> Option<Entity<LocalImageStore>> {
-        None
-    }
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -782,4 +966,24 @@ mod tests {
 
         assert_eq!(image1, image2);
     }
+
+    #[gpui::test]
+    fn test_compute_metadata_from_bytes() {
+        // Single white pixel PNG
+        let png_bytes = vec![
+            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
+            0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
+            0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78,
+            0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
+            0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
+        ];
+
+        let metadata = ImageItem::compute_metadata_from_bytes(&png_bytes).unwrap();
+
+        assert_eq!(metadata.width, 1);
+        assert_eq!(metadata.height, 1);
+        assert_eq!(metadata.file_size, png_bytes.len() as u64);
+        assert_eq!(metadata.format, image::ImageFormat::Png);
+        assert!(metadata.colors.is_some());
+    }
 }

crates/project/src/project.rs 🔗

@@ -1037,6 +1037,7 @@ impl Project {
         client.add_entity_request_handler(Self::handle_open_new_buffer);
         client.add_entity_message_handler(Self::handle_create_buffer_for_peer);
         client.add_entity_message_handler(Self::handle_toggle_lsp_logs);
+        client.add_entity_message_handler(Self::handle_create_image_for_peer);
 
         WorktreeStore::init(&client);
         BufferStore::init(&client);
@@ -1433,6 +1434,7 @@ impl Project {
             remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.agent_server_store);
 
             remote_proto.add_entity_message_handler(Self::handle_create_buffer_for_peer);
+            remote_proto.add_entity_message_handler(Self::handle_create_image_for_peer);
             remote_proto.add_entity_message_handler(Self::handle_update_worktree);
             remote_proto.add_entity_message_handler(Self::handle_update_project);
             remote_proto.add_entity_message_handler(Self::handle_toast);
@@ -2853,13 +2855,20 @@ impl Project {
         let weak_project = cx.entity().downgrade();
         cx.spawn(async move |_, cx| {
             let image_item = open_image_task.await?;
-            let project = weak_project.upgrade().context("Project dropped")?;
 
-            let metadata = ImageItem::load_image_metadata(image_item.clone(), project, cx).await?;
-            image_item.update(cx, |image_item, cx| {
-                image_item.image_metadata = Some(metadata);
-                cx.emit(ImageItemEvent::MetadataUpdated);
-            })?;
+            // Check if metadata already exists (e.g., for remote images)
+            let needs_metadata =
+                cx.read_entity(&image_item, |item, _| item.image_metadata.is_none())?;
+
+            if needs_metadata {
+                let project = weak_project.upgrade().context("Project dropped")?;
+                let metadata =
+                    ImageItem::load_image_metadata(image_item.clone(), project, cx).await?;
+                image_item.update(cx, |image_item, cx| {
+                    image_item.image_metadata = Some(metadata);
+                    cx.emit(ImageItemEvent::MetadataUpdated);
+                })?;
+            }
 
             Ok(image_item)
         })
@@ -3323,6 +3332,7 @@ impl Project {
         event: &ImageItemEvent,
         cx: &mut Context<Self>,
     ) -> Option<()> {
+        // TODO: handle image events from remote
         if let ImageItemEvent::ReloadNeeded = event
             && !self.is_via_collab()
         {
@@ -5060,6 +5070,20 @@ impl Project {
         buffer.read(cx).remote_id()
     }
 
+    async fn handle_create_image_for_peer(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::CreateImageForPeer>,
+        mut cx: AsyncApp,
+    ) -> Result<()> {
+        this.update(&mut cx, |this, cx| {
+            this.image_store.update(cx, |image_store, cx| {
+                image_store.handle_create_image_for_peer(envelope, cx)
+            })
+        })?
+        .log_err();
+        Ok(())
+    }
+
     fn synchronize_remote_buffers(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
         let project_id = match self.client_state {
             ProjectClientState::Remote {

crates/proto/proto/image.proto 🔗

@@ -0,0 +1,36 @@
+syntax = "proto3";
+package zed.messages;
+
+import "core.proto";
+import "worktree.proto";
+
+message OpenImageByPath {
+    uint64 project_id = 1;
+    uint64 worktree_id = 2;
+    string path = 3;
+}
+
+message OpenImageResponse {
+    uint64 image_id = 1;
+}
+
+message CreateImageForPeer {
+    uint64 project_id = 1;
+    PeerId peer_id = 2;
+    oneof variant {
+        ImageState state = 3;
+        ImageChunk chunk = 4;
+    }
+}
+
+message ImageState {
+    uint64 id = 1;
+    optional File file = 2;
+    uint64 content_size = 3;
+    string format = 4; // e.g., "png", "jpeg", "webp", etc.
+}
+
+message ImageChunk {
+    uint64 image_id = 1;
+    bytes data = 2;
+}

crates/proto/proto/zed.proto 🔗

@@ -9,6 +9,7 @@ import "channel.proto";
 import "core.proto";
 import "debugger.proto";
 import "git.proto";
+import "image.proto";
 import "lsp.proto";
 import "notification.proto";
 import "task.proto";
@@ -431,7 +432,11 @@ message Envelope {
 
         GitWorktreesResponse git_worktrees_response = 388;
         GitGetWorktrees git_get_worktrees = 389;
-        GitCreateWorktree git_create_worktree = 390; // current max
+        GitCreateWorktree git_create_worktree = 390;
+
+        OpenImageByPath open_image_by_path = 391;
+        OpenImageResponse open_image_response = 392;
+        CreateImageForPeer create_image_for_peer = 393; // current max
     }
 
     reserved 87 to 88;

crates/proto/src/proto.rs 🔗

@@ -51,6 +51,7 @@ messages!(
     (Commit, Background),
     (CopyProjectEntry, Foreground),
     (CreateBufferForPeer, Foreground),
+    (CreateImageForPeer, Foreground),
     (CreateChannel, Foreground),
     (CreateChannelResponse, Foreground),
     (CreateContext, Foreground),
@@ -179,9 +180,11 @@ messages!(
     (OnTypeFormattingResponse, Background),
     (OpenBufferById, Background),
     (OpenBufferByPath, Background),
+    (OpenImageByPath, Background),
     (OpenBufferForSymbol, Background),
     (OpenBufferForSymbolResponse, Background),
     (OpenBufferResponse, Background),
+    (OpenImageResponse, Background),
     (OpenCommitMessageBuffer, Background),
     (OpenContext, Foreground),
     (OpenContextResponse, Foreground),
@@ -397,6 +400,7 @@ request_messages!(
     (OnTypeFormatting, OnTypeFormattingResponse),
     (OpenBufferById, OpenBufferResponse),
     (OpenBufferByPath, OpenBufferResponse),
+    (OpenImageByPath, OpenImageResponse),
     (OpenBufferForSymbol, OpenBufferForSymbolResponse),
     (OpenCommitMessageBuffer, OpenBufferResponse),
     (OpenNewBuffer, OpenBufferResponse),
@@ -545,6 +549,7 @@ entity_messages!(
     GetColorPresentation,
     CopyProjectEntry,
     CreateBufferForPeer,
+    CreateImageForPeer,
     CreateProjectEntry,
     GetDocumentColor,
     DeleteProjectEntry,
@@ -581,6 +586,7 @@ entity_messages!(
     OpenNewBuffer,
     OpenBufferById,
     OpenBufferByPath,
+    OpenImageByPath,
     OpenBufferForSymbol,
     OpenCommitMessageBuffer,
     PerformRename,

crates/remote_server/Cargo.toml 🔗

@@ -39,6 +39,7 @@ git2 = { workspace = true, features = ["vendored-libgit2"] }
 gpui.workspace = true
 gpui_tokio.workspace = true
 http_client.workspace = true
+image.workspace = true
 json_schema_store.workspace = true
 language.workspace = true
 language_extension.workspace = true

crates/remote_server/src/headless_project.rs 🔗

@@ -1,4 +1,5 @@
 use anyhow::{Context as _, Result, anyhow};
+use language::File;
 use lsp::LanguageServerId;
 
 use extension::ExtensionHostProxy;
@@ -15,6 +16,7 @@ use project::{
     buffer_store::{BufferStore, BufferStoreEvent},
     debugger::{breakpoint_store::BreakpointStore, dap_store::DapStore},
     git_store::GitStore,
+    image_store::ImageId,
     lsp_store::log_store::{self, GlobalLogStore, LanguageServerKind},
     project_settings::SettingsObserver,
     search::SearchQuery,
@@ -29,8 +31,12 @@ use rpc::{
 use settings::{Settings as _, initial_server_settings_content};
 use smol::stream::StreamExt;
 use std::{
+    num::NonZeroU64,
     path::{Path, PathBuf},
-    sync::{Arc, atomic::AtomicUsize},
+    sync::{
+        Arc,
+        atomic::{AtomicU64, AtomicUsize, Ordering},
+    },
 };
 use sysinfo::{ProcessRefreshKind, RefreshKind, System, UpdateKind};
 use util::{ResultExt, paths::PathStyle, rel_path::RelPath};
@@ -260,6 +266,7 @@ impl HeadlessProject {
         session.add_entity_request_handler(Self::handle_open_server_settings);
         session.add_entity_request_handler(Self::handle_get_directory_environment);
         session.add_entity_message_handler(Self::handle_toggle_lsp_logs);
+        session.add_entity_request_handler(Self::handle_open_image_by_path);
 
         session.add_entity_request_handler(BufferStore::handle_update_buffer);
         session.add_entity_message_handler(BufferStore::handle_close_buffer);
@@ -525,6 +532,71 @@ impl HeadlessProject {
         })
     }
 
+    pub async fn handle_open_image_by_path(
+        this: Entity<Self>,
+        message: TypedEnvelope<proto::OpenImageByPath>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::OpenImageResponse> {
+        static NEXT_ID: AtomicU64 = AtomicU64::new(1);
+        let worktree_id = WorktreeId::from_proto(message.payload.worktree_id);
+        let path = RelPath::from_proto(&message.payload.path)?;
+        let project_id = message.payload.project_id;
+        use proto::create_image_for_peer::Variant;
+
+        let (worktree_store, session) = this.read_with(&cx, |this, _| {
+            (this.worktree_store.clone(), this.session.clone())
+        })?;
+
+        let worktree = worktree_store
+            .read_with(&cx, |store, cx| store.worktree_for_id(worktree_id, cx))?
+            .context("worktree not found")?;
+
+        let load_task = worktree.update(&mut cx, |worktree, cx| {
+            worktree.load_binary_file(path.as_ref(), cx)
+        })?;
+
+        let loaded_file = load_task.await?;
+        let content = loaded_file.content;
+        let file = loaded_file.file;
+
+        let proto_file = worktree.read_with(&cx, |_worktree, cx| file.to_proto(cx))?;
+        let image_id =
+            ImageId::from(NonZeroU64::new(NEXT_ID.fetch_add(1, Ordering::Relaxed)).unwrap());
+
+        let format = image::guess_format(&content)
+            .map(|f| format!("{:?}", f).to_lowercase())
+            .unwrap_or_else(|_| "unknown".to_string());
+
+        let state = proto::ImageState {
+            id: image_id.to_proto(),
+            file: Some(proto_file),
+            content_size: content.len() as u64,
+            format,
+        };
+
+        session.send(proto::CreateImageForPeer {
+            project_id,
+            peer_id: Some(REMOTE_SERVER_PEER_ID),
+            variant: Some(Variant::State(state)),
+        })?;
+
+        const CHUNK_SIZE: usize = 1024 * 1024; // 1MB chunks
+        for chunk in content.chunks(CHUNK_SIZE) {
+            session.send(proto::CreateImageForPeer {
+                project_id,
+                peer_id: Some(REMOTE_SERVER_PEER_ID),
+                variant: Some(Variant::Chunk(proto::ImageChunk {
+                    image_id: image_id.to_proto(),
+                    data: chunk.to_vec(),
+                })),
+            })?;
+        }
+
+        Ok(proto::OpenImageResponse {
+            image_id: image_id.to_proto(),
+        })
+    }
+
     pub async fn handle_open_new_buffer(
         this: Entity<Self>,
         _message: TypedEnvelope<proto::OpenNewBuffer>,