diff --git a/Cargo.lock b/Cargo.lock index bcfd7c3875d27f20cbe647c34555c4e915762fbe..ddc18ba3c0e5ce089d12139a28a737c05ca8de03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13969,6 +13969,7 @@ dependencies = [ "gpui", "gpui_tokio", "http_client", + "image", "json_schema_store", "language", "language_extension", diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index bfcab578f4b30357594cb460dfff53fd94d0ec05..f73631bb19c80a463ed38b78031dd0fe4d452681 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -346,6 +346,7 @@ impl Server { .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) + .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) @@ -395,6 +396,7 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .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::) .add_message_handler(broadcast_project_message_from_host::) @@ -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( diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index 8fcf9c8a6172f866d819e34cbf3b0b4810a8fc8d..b6dcf32b1bd84757cdc0c9c453e7743bbbe3d909 100644 --- a/crates/project/src/image_store.rs +++ b/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 { + 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, project: Entity, @@ -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>; fn as_local(&self) -> Option>; + fn as_remote(&self) -> Option>; } -struct RemoteImageStore {} +struct RemoteImageStore { + upstream_client: AnyProtoClient, + project_id: u64, + loading_remote_images_by_id: HashMap, + remote_image_listeners: + HashMap>>>>, + loaded_images: HashMap>, +} + +struct LoadingRemoteImage { + state: proto::ImageState, + chunks: Vec>, + received_size: u64, +} struct LocalImageStore { local_image_ids_by_path: HashMap, @@ -316,12 +336,18 @@ impl ImageStore { pub fn remote( worktree_store: Entity, - _upstream_client: AnyProtoClient, - _remote_id: u64, + upstream_client: AnyProtoClient, + project_id: u64, cx: &mut Context, ) -> 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, cx: &mut Context) -> 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, + cx: &mut Context, + ) -> 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, + ) -> Task>> { + 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, + worktree_store: &Entity, + cx: &mut Context, + ) -> Result>> { + 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 { @@ -520,6 +673,64 @@ impl ImageStoreImpl for Entity { fn as_local(&self) -> Option> { Some(self.clone()) } + + fn as_remote(&self) -> Option> { + None + } +} + +impl ImageStoreImpl for Entity { + fn open_image( + &self, + path: Arc, + worktree: Entity, + cx: &mut Context, + ) -> Task>> { + 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>, + _cx: &mut Context, + ) -> Task> { + Task::ready(Err(anyhow::anyhow!( + "Reloading images from remote is not supported" + ))) + } + + fn as_local(&self) -> Option> { + None + } + + fn as_remote(&self) -> Option> { + Some(self.clone()) + } } impl LocalImageStore { @@ -694,33 +905,6 @@ fn create_gpui_image(content: Vec) -> anyhow::Result> { ))) } -impl ImageStoreImpl for Entity { - fn open_image( - &self, - _path: Arc, - _worktree: Entity, - _cx: &mut Context, - ) -> Task>> { - Task::ready(Err(anyhow::anyhow!( - "Opening images from remote is not supported" - ))) - } - - fn reload_images( - &self, - _images: HashSet>, - _cx: &mut Context, - ) -> Task> { - Task::ready(Err(anyhow::anyhow!( - "Reloading images from remote is not supported" - ))) - } - - fn as_local(&self) -> Option> { - 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()); + } } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 38a75726859c3c4e7d5b7835365e6faac8634e3e..17f811034a5112831bf0d8fe4e21abee15cc7a9c 100644 --- a/crates/project/src/project.rs +++ b/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, ) -> 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, + envelope: TypedEnvelope, + 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) -> Task> { let project_id = match self.client_state { ProjectClientState::Remote { diff --git a/crates/proto/proto/image.proto b/crates/proto/proto/image.proto new file mode 100644 index 0000000000000000000000000000000000000000..e3232e6847cbc719280bc3ccd5254e5e368dbeb6 --- /dev/null +++ b/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; +} diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index c1551dca48a6d49eada9a0db65fbf770500d33b7..34987ba06754be2db31aea51b384e7e099dca728 100644 --- a/crates/proto/proto/zed.proto +++ b/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; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index cd125aa70fa440001f514a4d651d85687ce2456b..0d9ffd5e0491a65e5ff39a67af5a2efd015476fc 100644 --- a/crates/proto/src/proto.rs +++ b/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, diff --git a/crates/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml index 5034b24e0661eb87665d9b805b2bbc5d2ca577cd..f03851b9558d85514adde2afc10ad3f4cee77863 100644 --- a/crates/remote_server/Cargo.toml +++ b/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 diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 355e8a3fe272ce95e1f75f3df0d2a2872ae0a1fe..e9d34f4bdb94db401c7786f55fca6c4e429af15d 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/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, + message: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + 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, _message: TypedEnvelope,