From 2d43977f087569886d9b42ff09a717479f89b8df Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Wed, 12 Aug 2026 10:44:58 +0000 Subject: [PATCH] markdown_preview: Fix images not rendering over remote (#62490) Closes #39860 This PR resolves relative image paths from the Markdown source file's project path and load images through the project image store. SVG images over remote connections remain unsupported and are left for a follow-up. Release Notes: - Fixed images not rendering in Markdown Preview over remote. --- crates/agent_ui/src/conversation_view.rs | 2 +- crates/agent_ui/src/inline_prompt_editor.rs | 2 +- crates/markdown/src/html/html_rendering.rs | 8 +- crates/markdown/src/markdown.rs | 22 +- .../src/markdown_preview_view.rs | 270 +++++++++++++++--- crates/project/src/image_store.rs | 43 ++- .../remote_server/src/remote_editing_tests.rs | 89 +++++- 7 files changed, 383 insertions(+), 53 deletions(-) diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 1d0276c4d9..281e4797ab 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -3465,7 +3465,7 @@ fn render_agent_markdown( wrap_button_visibility: markdown::WrapButtonVisibility::VisibleOnHover, border: false, }) - .image_resolver(move |dest_url| resolve_agent_image(dest_url, &worktree_roots)) + .image_resolver(move |dest_url, _cx| resolve_agent_image(dest_url, &worktree_roots)) .on_url_click(move |text, window, cx| { thread_view::open_link(text, &workspace, window, cx); }) diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index b046a5dfed..9d506aa5e4 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -1160,7 +1160,7 @@ impl PromptEditor { fn render_markdown(&self, markdown: Entity, style: MarkdownStyle) -> MarkdownElement { MarkdownElement::new(markdown, style) - .image_resolver(|dest_url| crate::resolve_agent_image(dest_url, &[])) + .image_resolver(|dest_url, _cx| crate::resolve_agent_image(dest_url, &[])) } } diff --git a/crates/markdown/src/html/html_rendering.rs b/crates/markdown/src/html/html_rendering.rs index 00b73f1e44..f9c1568445 100644 --- a/crates/markdown/src/html/html_rendering.rs +++ b/crates/markdown/src/html/html_rendering.rs @@ -136,7 +136,7 @@ impl MarkdownElement { self.render_html_table(table, source_allocator, builder, markdown_end, cx); } ParsedHtmlElement::Image(image) => { - self.render_html_image(image, builder); + self.render_html_image(image, builder, cx); } } } @@ -358,7 +358,7 @@ impl MarkdownElement { self.render_html_text(text, source_allocator, builder, cx); } HtmlParagraphChunk::Image(image) => { - self.render_html_image(image, builder); + self.render_html_image(image, builder, cx); } } } @@ -447,11 +447,11 @@ impl MarkdownElement { } } - fn render_html_image(&self, image: &HtmlImage, builder: &mut MarkdownElementBuilder) { + fn render_html_image(&self, image: &HtmlImage, builder: &mut MarkdownElementBuilder, cx: &App) { let Some(source) = self .image_resolver .as_ref() - .and_then(|resolve| resolve(image.dest_url.as_ref())) + .and_then(|resolve| resolve(image.dest_url.as_ref(), cx)) else { return; }; diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 07b4d9e962..55ccd4dfea 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -1511,7 +1511,7 @@ pub struct MarkdownElement { on_source_click: Option, on_checkbox_toggle: Option, on_mermaid_zoom: Option, - image_resolver: Option Option>>, + image_resolver: Option Option>>, show_root_block_markers: bool, autoscroll: AutoscrollBehavior, /// Test-only hook to observe the laid-out text when this element is @@ -1629,7 +1629,7 @@ impl MarkdownElement { pub fn image_resolver( mut self, - resolver: impl Fn(&str) -> Option + 'static, + resolver: impl Fn(&str, &App) -> Option + 'static, ) -> Self { self.image_resolver = Some(Box::new(resolver)); self @@ -2499,7 +2499,7 @@ impl Element for MarkdownElement { } else if let Some(source) = self .image_resolver .as_ref() - .and_then(|resolve| resolve(dest_url.as_ref())) + .and_then(|resolve| resolve(dest_url.as_ref(), cx)) { current_img_block_range = Some(range.clone()); self.push_markdown_image( @@ -4823,7 +4823,7 @@ mod tests { fn render_markdown_with_image_resolver( markdown: &str, options: MarkdownOptions, - resolver: impl Fn(&str) -> Option + 'static, + resolver: impl Fn(&str, &App) -> Option + 'static, cx: &mut TestAppContext, ) -> RenderedText { ensure_theme_initialized(cx); @@ -4866,7 +4866,7 @@ mod tests { let rendered = render_markdown_with_image_resolver( "Here is an image ![alt](https://example.com/a.png) and more text\nthat continues", MarkdownOptions::default(), - move |_| Some(ImageSource::Render(image.clone())), + move |_, _| Some(ImageSource::Render(image.clone())), cx, ); let text: String = rendered @@ -4890,7 +4890,7 @@ mod tests { let rendered = render_markdown_with_image_resolver( "![alt](https://example.com/a.png)\ncaption", MarkdownOptions::default(), - move |_| Some(ImageSource::Render(image.clone())), + move |_, _| Some(ImageSource::Render(image.clone())), cx, ); let text: String = rendered @@ -4913,7 +4913,7 @@ mod tests { parse_html: true, ..Default::default() }, - move |_| Some(ImageSource::Render(image.clone())), + move |_, _| Some(ImageSource::Render(image.clone())), cx, ); let stray_whitespace_line = rendered.lines.iter().find_map(|line| { @@ -4936,7 +4936,7 @@ mod tests { parse_html: true, ..Default::default() }, - move |_| Some(ImageSource::Render(image.clone())), + move |_, _| Some(ImageSource::Render(image.clone())), cx, ); let text: String = rendered @@ -4979,7 +4979,7 @@ mod tests { size(px(600.0), px(600.0)), |_window, _cx| { MarkdownElement::new(markdown.clone(), style) - .image_resolver(move |_| Some(ImageSource::Render(image.clone()))) + .image_resolver(move |_, _| Some(ImageSource::Render(image.clone()))) .code_block_renderer(CodeBlockRenderer::Default { copy_button_visibility: CopyButtonVisibility::Hidden, wrap_button_visibility: WrapButtonVisibility::Hidden, @@ -5818,7 +5818,7 @@ mod tests { let hovered_urls = self.hovered_urls.clone(); div().size_full().child( MarkdownElement::new(self.markdown.clone(), MarkdownStyle::default()) - .image_resolver(|_| Some(loaded_image_source())) + .image_resolver(|_, _| Some(loaded_image_source())) .on_url_hover(move |url, _, _| { hovered_urls.borrow_mut().push(url); }), @@ -5961,7 +5961,7 @@ mod tests { let image_source = self.image_source.clone(); div().size_full().child( MarkdownElement::new(self.markdown.clone(), MarkdownStyle::default()) - .image_resolver(move |_| Some(image_source.clone())), + .image_resolver(move |_, _| Some(image_source.clone())), ) } } diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 4c824b574d..d6946a877a 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -23,7 +23,7 @@ use markdown::{ MarkdownOptions, MarkdownStyle, }; use project::search::SearchQuery; -use project::{Project, ProjectPath}; +use project::{Project, ProjectPath, image_store}; use settings::{SeedQuerySetting, Settings, update_settings_file}; use theme::{SystemAppearance, Theme, ThemeRegistry}; use theme_settings::ThemeSettings; @@ -34,7 +34,8 @@ use ui::{ use util::{ ResultExt, markdown::{source_position_from_fragment, split_local_url_fragment}, - paths::PathWithPosition, + paths::{PathStyle, PathWithPosition}, + rel_path::RelPath, }; use workspace::item::{Item, ItemBufferKind, ItemHandle, SaveOptions, SerializableItem}; use workspace::notifications::{NotifyResultExt, NotifyTaskExt}; @@ -701,21 +702,19 @@ impl MarkdownPreviewView { /// The absolute path of the file that is currently being previewed. fn get_folder_for_active_editor(editor: &Editor, cx: &App) -> Option { let file = editor.file_at(MultiBufferOffset(0), cx)?; + let project_path = ProjectPath::from_file(file.as_ref(), cx); let absolute_path = editor .project() - .and_then(|project| { - project.read(cx).absolute_path( - &ProjectPath { - worktree_id: file.worktree_id(cx), - path: file.path().clone(), - }, - cx, - ) - }) + .and_then(|project| project.read(cx).absolute_path(&project_path, cx)) .or_else(|| file.as_local().map(|file| file.abs_path(cx)))?; absolute_path.parent().map(Path::to_path_buf) } + fn project_path_for_active_editor(editor: &Editor, cx: &App) -> Option { + let file = editor.file_at(MultiBufferOffset(0), cx)?; + Some(ProjectPath::from_file(file.as_ref(), cx)) + } + fn line_scroll_amount(&self, cx: &App) -> Pixels { let settings = ThemeSettings::get_global(cx); settings.markdown_preview_font_size(cx) * settings.buffer_line_height.value() @@ -945,13 +944,18 @@ impl MarkdownPreviewView { .active_editor .as_ref() .map(|state| state.editor.clone()); + let source_project_path = active_editor + .as_ref() + .and_then(|editor| Self::project_path_for_active_editor(editor.read(cx), cx)); + let mut project = None; let mut workspace_directory = None; if let Some(workspace_entity) = self.workspace.upgrade() { - let project = workspace_entity.read(cx).project(); - if let Some(tree) = project.read(cx).worktrees(cx).next() { + let project_entity = workspace_entity.read(cx).project().clone(); + if let Some(tree) = project_entity.read(cx).worktrees(cx).next() { workspace_directory = Some(tree.read(cx).abs_path().to_path_buf()); } + project = Some(project_entity); } let markdown_style = if let Some(theme) = preview_theme { @@ -976,11 +980,15 @@ impl MarkdownPreviewView { .show_root_block_markers() .image_resolver({ let base_directory = self.base_directory.clone(); - move |dest_url| { + move |dest_url, cx| { resolve_preview_image( dest_url, base_directory.as_deref(), workspace_directory.as_deref(), + project.as_ref(), + source_project_path.as_ref(), + PathStyle::local(), + cx, ) } }) @@ -1372,6 +1380,10 @@ fn resolve_preview_image( dest_url: &str, base_directory: Option<&Path>, workspace_directory: Option<&Path>, + project: Option<&Entity>, + source_project_path: Option<&ProjectPath>, + client_path_style: PathStyle, + cx: &App, ) -> Option { if dest_url.starts_with("data:") { return None; @@ -1386,33 +1398,88 @@ fn resolve_preview_image( let decoded = urlencoding::decode(dest_url) .map(|decoded| decoded.into_owned()) .unwrap_or_else(|_| dest_url.to_string()); - - if let Some(stripped) = ['/', '\\'] + let workspace_relative_path = ['/', '\\'] .iter() - .find_map(|prefix| decoded.strip_prefix(*prefix)) - { - if let Some(root) = workspace_directory { - let absolute_path = root.join(stripped); - if absolute_path.exists() { - return Some(ImageSource::Resource(Resource::Path(Arc::from( - absolute_path.as_path(), - )))); - } else { - return None; + .find_map(|prefix| decoded.strip_prefix(*prefix)); + + if let (Some(project), Some(source_project_path)) = (project, source_project_path) { + let project_path = resolve_project_path_for_preview_image( + &decoded, + workspace_relative_path, + source_project_path, + project, + cx, + ); + if let Some(project_path) = project_path { + let is_remote = project + .read(cx) + .worktree_for_id(project_path.worktree_id, cx) + .is_some_and(|worktree| !worktree.read(cx).is_local()); + if is_remote { + return Some(image_store::project_image_source( + project.downgrade(), + project_path, + )); } + + let path = project.read(cx).absolute_path(&project_path, cx)?; + return path + .exists() + .then(|| ImageSource::Resource(Resource::Path(Arc::from(path.as_path())))); + } else if project.read(cx).is_remote() { + return None; } } - let path = if Path::new(&decoded).is_absolute() { + let path = if let (Some(stripped), Some(root)) = (workspace_relative_path, workspace_directory) + { + client_path_style.join_path(root, stripped).ok()? + } else if client_path_style.is_absolute(&decoded) { PathBuf::from(decoded) } else { - base_directory?.join(decoded) + client_path_style.join_path(base_directory?, decoded).ok()? }; path.exists() .then(|| ImageSource::Resource(Resource::Path(Arc::from(path.as_path())))) } +fn resolve_project_path_for_preview_image( + decoded_path: &str, + workspace_relative_path: Option<&str>, + source_project_path: &ProjectPath, + project: &Entity, + cx: &App, +) -> Option { + let worktree = project + .read(cx) + .worktree_for_id(source_project_path.worktree_id, cx)?; + let path_style = worktree.read(cx).path_style(); + + if workspace_relative_path.is_none() && path_style.is_absolute(decoded_path) { + return project + .read(cx) + .project_path_for_absolute_path(Path::new(decoded_path), cx); + } + + let source_directory = source_project_path.path.parent()?; + let path = if let Some(workspace_relative_path) = workspace_relative_path { + RelPath::new(Path::new(workspace_relative_path), path_style) + .ok()? + .into_owned() + } else { + let joined_path = path_style + .join_path(source_directory.as_std_path(), decoded_path) + .ok()?; + RelPath::new(&joined_path, path_style).ok()?.into_owned() + }; + + Some(ProjectPath { + worktree_id: source_project_path.worktree_id, + path: path.into(), + }) +} + impl Focusable for MarkdownPreviewView { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() @@ -1990,19 +2057,21 @@ mod tests { use crate::markdown_preview_view::ImageSource; use crate::markdown_preview_view::Resource; use crate::markdown_preview_view::resolve_preview_image; + use crate::markdown_preview_view::resolve_project_path_for_preview_image; use buffer_diff::BufferDiff; use editor::Editor; use fs::FakeFs; use gpui::{ - AppContext as _, Entity, Focusable as _, Modifiers, TestAppContext, WindowHandle, px, + App, AppContext as _, Entity, Focusable as _, Modifiers, TestAppContext, WindowHandle, px, }; use language::{Buffer, DiskState, Point}; - use project::Project; + use project::{Project, ProjectPath}; use serde_json::json; - use std::path::PathBuf; + use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use util::path; + use util::paths::PathStyle; use util::rel_path::{RelPath, rel_path}; use util::test::TempTree; use workspace::item::SerializableItem; @@ -2012,8 +2081,8 @@ mod tests { use super::{MarkdownPreviewView, open_preview_url}; - #[test] - fn resolves_workspace_absolute_preview_image_path_and_rejects_missing() { + #[gpui::test] + fn resolves_workspace_absolute_preview_image_path_and_rejects_missing(cx: &mut App) { let tree = TempTree::new(json!({ "docs": {}, "test_image.png": "mock data" @@ -2027,6 +2096,10 @@ mod tests { workspace_root_relative_path, Some(&base_directory), Some(workspace_directory), + None, + None, + PathStyle::local(), + cx, ); assert_resolved_preview_image_path(resolved, image_file.as_path()); } @@ -2035,10 +2108,141 @@ mod tests { "/missing_image.png", Some(&base_directory), Some(workspace_directory), + None, + None, + PathStyle::local(), + cx, ); assert!(missing.is_none()); } + #[gpui::test] + async fn resolves_remote_preview_image_through_project(cx: &mut TestAppContext) { + init_test(cx); + let project = Project::test(FakeFs::new(cx.executor()), [], cx).await; + let worktree = project.update(cx, |project, cx| { + project.add_test_remote_worktree("/remote/project", cx) + }); + let source_project_path = ProjectPath { + worktree_id: worktree.read_with(cx, |worktree, _cx| worktree.id()), + path: rel_path("docs/readme.md").into(), + }; + + let resolved = cx.update(|cx| { + resolve_preview_image( + "images/example.png", + Some(Path::new("/remote/project/docs")), + Some(Path::new("/remote/project")), + Some(&project), + Some(&source_project_path), + PathStyle::local(), + cx, + ) + }); + assert!(matches!(resolved, Some(ImageSource::Custom(_)))); + + let outside_worktree = cx.update(|cx| { + resolve_preview_image( + "../../outside/example.png", + Some(Path::new("/remote/project/docs")), + Some(Path::new("/remote/project")), + Some(&project), + Some(&source_project_path), + PathStyle::local(), + cx, + ) + }); + assert!(outside_worktree.is_none()); + } + + #[gpui::test] + async fn resolves_remote_preview_image_with_different_client_path_style( + cx: &mut TestAppContext, + ) { + init_test(cx); + let project = Project::test(FakeFs::new(cx.executor()), [], cx).await; + let worktree = project.update(cx, |project, cx| { + project.add_test_remote_worktree("/remote/project", cx) + }); + let source_project_path = ProjectPath { + worktree_id: worktree.read_with(cx, |worktree, _cx| worktree.id()), + path: rel_path("docs/readme.md").into(), + }; + + let resolved = cx.update(|cx| { + resolve_preview_image( + "images/example.png", + Some(Path::new("/remote/project/docs")), + Some(Path::new("/remote/project")), + Some(&project), + Some(&source_project_path), + PathStyle::Windows, + cx, + ) + }); + + assert!(matches!(resolved, Some(ImageSource::Custom(_)))); + let project_path = cx.update(|cx| { + resolve_project_path_for_preview_image( + "images/example.png", + None, + &source_project_path, + &project, + cx, + ) + }); + assert_eq!( + project_path + .expect("image should resolve within the remote worktree") + .path, + rel_path("docs/images/example.png").into() + ); + } + + #[gpui::test] + async fn resolves_workspace_relative_remote_image_in_source_worktree(cx: &mut TestAppContext) { + init_test(cx); + let project = Project::test(FakeFs::new(cx.executor()), [], cx).await; + let source_worktree = project.update(cx, |project, cx| { + project.add_test_remote_worktree("/remote/other", cx); + project.add_test_remote_worktree("/remote/project", cx) + }); + let source_project_path = ProjectPath { + worktree_id: source_worktree.read_with(cx, |worktree, _cx| worktree.id()), + path: rel_path("docs/readme.md").into(), + }; + + let resolved = cx.update(|cx| { + resolve_preview_image( + "/images/root.png", + Some(Path::new("/remote/project/docs")), + Some(Path::new("/remote/other")), + Some(&project), + Some(&source_project_path), + PathStyle::Windows, + cx, + ) + }); + assert!(matches!(resolved, Some(ImageSource::Custom(_)))); + + let project_path = cx.update(|cx| { + resolve_project_path_for_preview_image( + "/images/root.png", + Some("images/root.png"), + &source_project_path, + &project, + cx, + ) + }); + assert_eq!( + project_path, + Some(ProjectPath { + worktree_id: source_project_path.worktree_id, + path: rel_path("images/root.png").into(), + }) + ); + } + #[gpui::test] async fn opens_preview_file_links_at_positions(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index 667440a437..a667f21788 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -6,12 +6,14 @@ use anyhow::{Context as _, Result}; use collections::{HashMap, HashSet, hash_map}; use futures::{StreamExt, channel::oneshot}; use gpui::{ - App, AsyncApp, Context, Entity, EventEmitter, Img, Subscription, Task, WeakEntity, prelude::*, + App, Asset, AssetLogger, AsyncApp, Context, Entity, EventEmitter, ImageCacheError, ImageSource, + Img, RenderImage, Subscription, Task, WeakEntity, prelude::*, }; pub use image::ImageFormat; use image::{ExtendedColorType, GenericImageView, ImageReader}; use language::{DiskState, File}; use rpc::{AnyProtoClient, ErrorExt as _, TypedEnvelope, proto}; +use std::future::Future; use std::num::NonZeroU64; use std::path::PathBuf; use std::sync::Arc; @@ -107,6 +109,45 @@ pub struct ImageItem { pub image_metadata: Option, } +#[derive(Clone, Eq, Hash, PartialEq)] +struct ProjectImageSource { + project: WeakEntity, + path: ProjectPath, +} + +enum ProjectImageAsset {} + +impl Asset for ProjectImageAsset { + type Source = ProjectImageSource; + type Output = Result, ImageCacheError>; + + fn load( + source: Self::Source, + cx: &mut App, + ) -> impl Future + Send + 'static { + let svg_renderer = cx.svg_renderer(); + let load_image = cx.spawn(async move |cx| { + let open_image = source + .project + .update(cx, |project, cx| project.open_image(source.path, cx))?; + let image = open_image.await?; + Ok::<_, anyhow::Error>(image.read_with(cx, |image, _cx| image.image.clone())) + }); + + async move { + let image = load_image.await?; + image.to_image_data(svg_renderer).map_err(Into::into) + } + } +} + +pub fn project_image_source(project: WeakEntity, path: ProjectPath) -> ImageSource { + let source = ProjectImageSource { project, path }; + ImageSource::from(move |window: &mut gpui::Window, cx: &mut App| { + window.use_asset::>(&source, cx) + }) +} + impl ImageItem { pub fn compute_metadata_from_bytes(image_bytes: &[u8]) -> Result { let image_format = image::guess_format(image_bytes)?; diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 271ce2ad27..7948e1b6a7 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -24,7 +24,10 @@ use git::{ Oid, repository::{CommitData, GitCommitTemplate, RepoPath, Worktree as GitWorktree}, }; -use gpui::{AppContext as _, Entity, SharedString, TestAppContext, UpdateGlobal, VisualContext}; +use gpui::{ + AppContext as _, Entity, ImageSource, IntoElement as _, SharedString, TestAppContext, + UpdateGlobal, VisualContext, img, px, size, +}; use http_client::{BlockedHttpClient, FakeHttpClient}; use language::{ Buffer, FakeLspAdapter, LanguageConfig, LanguageMatcher, LanguageRegistry, LineEnding, Point, @@ -36,8 +39,9 @@ use lsp::{ }; use node_runtime::NodeRuntime; use project::{ - ProgressToken, Project, + ProgressToken, Project, ProjectPath, agent_server_store::AgentServerCommand, + image_store, search::{SearchQuery, SearchResult}, }; use remote::RemoteClient; @@ -340,6 +344,87 @@ async fn do_search_and_assert( buffers } +struct RemoteImageTestView { + source: ImageSource, +} + +impl gpui::Render for RemoteImageTestView { + fn render( + &mut self, + _window: &mut gpui::Window, + _cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + img(self.source.clone()) + } +} + +#[gpui::test] +async fn test_remote_project_image_source(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + path!("/code"), + json!({ + "project": { + "docs": { + "image.ppm": "P3\n1 1\n255\n255 0 0\n" + } + } + }), + ) + .await; + + let (project, _headless) = init_test(&fs, cx, server_cx).await; + let (worktree, _) = project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/code/project"), true, cx) + }) + .await + .expect("remote worktree should open"); + let worktree_id = worktree.read_with(cx, |worktree, _cx| worktree.id()); + let source = image_store::project_image_source( + project.downgrade(), + ProjectPath { + worktree_id, + path: rel_path("docs/image.ppm").into(), + }, + ); + let ImageSource::Custom(load_image) = source else { + panic!("expected a project-backed image source"); + }; + let loaded_bytes = Arc::new(std::sync::Mutex::new(None)); + let observed_source = ImageSource::from({ + let loaded_bytes = loaded_bytes.clone(); + move |window: &mut gpui::Window, cx: &mut gpui::App| { + let result = load_image(window, cx); + if let Some(Ok(image)) = &result { + *loaded_bytes.lock().expect("loaded image mutex poisoned") = + image.as_bytes(0).map(ToOwned::to_owned); + } + result + } + }); + + let (view, cx) = cx.add_window_view(|_window, _cx| RemoteImageTestView { + source: observed_source, + }); + cx.draw(Default::default(), size(px(10.), px(10.)), { + let view = view.clone(); + move |_, _| view.into_any_element() + }); + cx.run_until_parked(); + cx.draw(Default::default(), size(px(10.), px(10.)), move |_, _| { + view.into_any_element() + }); + + assert_eq!( + loaded_bytes + .lock() + .expect("loaded image mutex poisoned") + .as_deref(), + Some([0, 0, 255, 255].as_slice()) + ); +} + #[gpui::test] async fn test_remote_project_search(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { let fs = FakeFs::new(server_cx.executor());