1use crate::{
2 Project, ProjectEntryId, ProjectItem, ProjectPath,
3 worktree_store::{WorktreeStore, WorktreeStoreEvent},
4};
5use anyhow::{Context as _, Result};
6use collections::{HashMap, HashSet, hash_map};
7use futures::{StreamExt, channel::oneshot};
8use gpui::{
9 App, AsyncApp, Context, Entity, EventEmitter, Img, Subscription, Task, WeakEntity, prelude::*,
10};
11pub use image::ImageFormat;
12use image::{ExtendedColorType, GenericImageView, ImageReader};
13use language::{DiskState, File};
14use rpc::{AnyProtoClient, ErrorExt as _};
15use std::num::NonZeroU64;
16use std::path::Path;
17use std::sync::Arc;
18use std::{ffi::OsStr, path::PathBuf};
19use util::ResultExt;
20use worktree::{LoadedBinaryFile, PathChange, Worktree};
21
22#[derive(Clone, Copy, Debug, Hash, PartialEq, PartialOrd, Ord, Eq)]
23pub struct ImageId(NonZeroU64);
24
25impl std::fmt::Display for ImageId {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 write!(f, "{}", self.0)
28 }
29}
30
31impl From<NonZeroU64> for ImageId {
32 fn from(id: NonZeroU64) -> Self {
33 ImageId(id)
34 }
35}
36
37#[derive(Debug)]
38pub enum ImageItemEvent {
39 ReloadNeeded,
40 Reloaded,
41 FileHandleChanged,
42 MetadataUpdated,
43}
44
45impl EventEmitter<ImageItemEvent> for ImageItem {}
46
47pub enum ImageStoreEvent {
48 ImageAdded(Entity<ImageItem>),
49}
50
51impl EventEmitter<ImageStoreEvent> for ImageStore {}
52
53#[derive(Debug, Clone, Copy)]
54pub struct ImageMetadata {
55 pub width: u32,
56 pub height: u32,
57 pub file_size: u64,
58 pub colors: Option<ImageColorInfo>,
59 pub format: ImageFormat,
60}
61
62#[derive(Debug, Clone, Copy)]
63pub struct ImageColorInfo {
64 pub channels: u8,
65 pub bits_per_channel: u8,
66}
67
68impl ImageColorInfo {
69 pub fn from_color_type(color_type: impl Into<ExtendedColorType>) -> Option<Self> {
70 let (channels, bits_per_channel) = match color_type.into() {
71 ExtendedColorType::L8 => (1, 8),
72 ExtendedColorType::L16 => (1, 16),
73 ExtendedColorType::La8 => (2, 8),
74 ExtendedColorType::La16 => (2, 16),
75 ExtendedColorType::Rgb8 => (3, 8),
76 ExtendedColorType::Rgb16 => (3, 16),
77 ExtendedColorType::Rgba8 => (4, 8),
78 ExtendedColorType::Rgba16 => (4, 16),
79 ExtendedColorType::A8 => (1, 8),
80 ExtendedColorType::Bgr8 => (3, 8),
81 ExtendedColorType::Bgra8 => (4, 8),
82 ExtendedColorType::Cmyk8 => (4, 8),
83 _ => return None,
84 };
85
86 Some(Self {
87 channels,
88 bits_per_channel,
89 })
90 }
91
92 pub const fn bits_per_pixel(&self) -> u8 {
93 self.channels * self.bits_per_channel
94 }
95}
96
97pub struct ImageItem {
98 pub id: ImageId,
99 pub file: Arc<worktree::File>,
100 pub image: Arc<gpui::Image>,
101 reload_task: Option<Task<()>>,
102 pub image_metadata: Option<ImageMetadata>,
103}
104
105impl ImageItem {
106 pub async fn load_image_metadata(
107 image: Entity<ImageItem>,
108 project: Entity<Project>,
109 cx: &mut AsyncApp,
110 ) -> Result<ImageMetadata> {
111 let (fs, image_path) = cx.update(|cx| {
112 let fs = project.read(cx).fs().clone();
113 let image_path = image
114 .read(cx)
115 .abs_path(cx)
116 .context("absolutizing image file path")?;
117 anyhow::Ok((fs, image_path))
118 })??;
119
120 let image_bytes = fs.load_bytes(&image_path).await?;
121 let image_format = image::guess_format(&image_bytes)?;
122
123 let mut image_reader = ImageReader::new(std::io::Cursor::new(image_bytes));
124 image_reader.set_format(image_format);
125 let image = image_reader.decode()?;
126
127 let (width, height) = image.dimensions();
128 let file_metadata = fs
129 .metadata(image_path.as_path())
130 .await?
131 .context("failed to load image metadata")?;
132
133 Ok(ImageMetadata {
134 width,
135 height,
136 file_size: file_metadata.len,
137 format: image_format,
138 colors: ImageColorInfo::from_color_type(image.color()),
139 })
140 }
141
142 pub fn project_path(&self, cx: &App) -> ProjectPath {
143 ProjectPath {
144 worktree_id: self.file.worktree_id(cx),
145 path: self.file.path().clone(),
146 }
147 }
148
149 pub fn abs_path(&self, cx: &App) -> Option<PathBuf> {
150 Some(self.file.as_local()?.abs_path(cx))
151 }
152
153 fn file_updated(&mut self, new_file: Arc<worktree::File>, cx: &mut Context<Self>) {
154 let mut file_changed = false;
155
156 let old_file = &self.file;
157 if new_file.path() != old_file.path() {
158 file_changed = true;
159 }
160
161 let old_state = old_file.disk_state();
162 let new_state = new_file.disk_state();
163 if old_state != new_state {
164 file_changed = true;
165 if matches!(new_state, DiskState::Present { .. }) {
166 cx.emit(ImageItemEvent::ReloadNeeded)
167 }
168 }
169
170 self.file = new_file;
171 if file_changed {
172 cx.emit(ImageItemEvent::FileHandleChanged);
173 cx.notify();
174 }
175 }
176
177 fn reload(&mut self, cx: &mut Context<Self>) -> Option<oneshot::Receiver<()>> {
178 let local_file = self.file.as_local()?;
179 let (tx, rx) = futures::channel::oneshot::channel();
180
181 let content = local_file.load_bytes(cx);
182 self.reload_task = Some(cx.spawn(async move |this, cx| {
183 if let Some(image) = content
184 .await
185 .context("Failed to load image content")
186 .and_then(create_gpui_image)
187 .log_err()
188 {
189 this.update(cx, |this, cx| {
190 this.image = image;
191 cx.emit(ImageItemEvent::Reloaded);
192 })
193 .log_err();
194 }
195 _ = tx.send(());
196 }));
197 Some(rx)
198 }
199}
200
201pub fn is_image_file(project: &Entity<Project>, path: &ProjectPath, cx: &App) -> bool {
202 let ext = util::maybe!({
203 let worktree_abs_path = project
204 .read(cx)
205 .worktree_for_id(path.worktree_id, cx)?
206 .read(cx)
207 .abs_path();
208 path.path
209 .extension()
210 .or_else(|| worktree_abs_path.extension())
211 .and_then(OsStr::to_str)
212 .map(str::to_lowercase)
213 });
214
215 match ext {
216 Some(ext) => Img::extensions().contains(&ext.as_str()) && !ext.contains("svg"),
217 None => false,
218 }
219}
220
221impl ProjectItem for ImageItem {
222 fn try_open(
223 project: &Entity<Project>,
224 path: &ProjectPath,
225 cx: &mut App,
226 ) -> Option<Task<anyhow::Result<Entity<Self>>>> {
227 if is_image_file(project, path, cx) {
228 Some(cx.spawn({
229 let path = path.clone();
230 let project = project.clone();
231 async move |cx| {
232 project
233 .update(cx, |project, cx| project.open_image(path, cx))?
234 .await
235 }
236 }))
237 } else {
238 None
239 }
240 }
241
242 fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
243 self.file.entry_id
244 }
245
246 fn project_path(&self, cx: &App) -> Option<ProjectPath> {
247 Some(self.project_path(cx).clone())
248 }
249
250 fn is_dirty(&self) -> bool {
251 false
252 }
253}
254
255trait ImageStoreImpl {
256 fn open_image(
257 &self,
258 path: Arc<Path>,
259 worktree: Entity<Worktree>,
260 cx: &mut Context<ImageStore>,
261 ) -> Task<Result<Entity<ImageItem>>>;
262
263 fn reload_images(
264 &self,
265 images: HashSet<Entity<ImageItem>>,
266 cx: &mut Context<ImageStore>,
267 ) -> Task<Result<()>>;
268
269 fn as_local(&self) -> Option<Entity<LocalImageStore>>;
270}
271
272struct RemoteImageStore {}
273
274struct LocalImageStore {
275 local_image_ids_by_path: HashMap<ProjectPath, ImageId>,
276 local_image_ids_by_entry_id: HashMap<ProjectEntryId, ImageId>,
277 image_store: WeakEntity<ImageStore>,
278 _subscription: Subscription,
279}
280
281pub struct ImageStore {
282 state: Box<dyn ImageStoreImpl>,
283 opened_images: HashMap<ImageId, WeakEntity<ImageItem>>,
284 worktree_store: Entity<WorktreeStore>,
285 #[allow(clippy::type_complexity)]
286 loading_images_by_path: HashMap<
287 ProjectPath,
288 postage::watch::Receiver<Option<Result<Entity<ImageItem>, Arc<anyhow::Error>>>>,
289 >,
290}
291
292impl ImageStore {
293 pub fn local(worktree_store: Entity<WorktreeStore>, cx: &mut Context<Self>) -> Self {
294 let this = cx.weak_entity();
295 Self {
296 state: Box::new(cx.new(|cx| {
297 let subscription = cx.subscribe(
298 &worktree_store,
299 |this: &mut LocalImageStore, _, event, cx| {
300 if let WorktreeStoreEvent::WorktreeAdded(worktree) = event {
301 this.subscribe_to_worktree(worktree, cx);
302 }
303 },
304 );
305
306 LocalImageStore {
307 local_image_ids_by_path: Default::default(),
308 local_image_ids_by_entry_id: Default::default(),
309 image_store: this,
310 _subscription: subscription,
311 }
312 })),
313 opened_images: Default::default(),
314 loading_images_by_path: Default::default(),
315 worktree_store,
316 }
317 }
318
319 pub fn remote(
320 worktree_store: Entity<WorktreeStore>,
321 _upstream_client: AnyProtoClient,
322 _remote_id: u64,
323 cx: &mut Context<Self>,
324 ) -> Self {
325 Self {
326 state: Box::new(cx.new(|_| RemoteImageStore {})),
327 opened_images: Default::default(),
328 loading_images_by_path: Default::default(),
329 worktree_store,
330 }
331 }
332
333 pub fn images(&self) -> impl '_ + Iterator<Item = Entity<ImageItem>> {
334 self.opened_images
335 .values()
336 .filter_map(|image| image.upgrade())
337 }
338
339 pub fn get(&self, image_id: ImageId) -> Option<Entity<ImageItem>> {
340 self.opened_images
341 .get(&image_id)
342 .and_then(|image| image.upgrade())
343 }
344
345 pub fn get_by_path(&self, path: &ProjectPath, cx: &App) -> Option<Entity<ImageItem>> {
346 self.images()
347 .find(|image| &image.read(cx).project_path(cx) == path)
348 }
349
350 pub fn open_image(
351 &mut self,
352 project_path: ProjectPath,
353 cx: &mut Context<Self>,
354 ) -> Task<Result<Entity<ImageItem>>> {
355 let existing_image = self.get_by_path(&project_path, cx);
356 if let Some(existing_image) = existing_image {
357 return Task::ready(Ok(existing_image));
358 }
359
360 let Some(worktree) = self
361 .worktree_store
362 .read(cx)
363 .worktree_for_id(project_path.worktree_id, cx)
364 else {
365 return Task::ready(Err(anyhow::anyhow!("no such worktree")));
366 };
367
368 let loading_watch = match self.loading_images_by_path.entry(project_path.clone()) {
369 // If the given path is already being loaded, then wait for that existing
370 // task to complete and return the same image.
371 hash_map::Entry::Occupied(e) => e.get().clone(),
372
373 // Otherwise, record the fact that this path is now being loaded.
374 hash_map::Entry::Vacant(entry) => {
375 let (mut tx, rx) = postage::watch::channel();
376 entry.insert(rx.clone());
377
378 let project_path = project_path.clone();
379 let load_image = self
380 .state
381 .open_image(project_path.path.clone(), worktree, cx);
382
383 cx.spawn(async move |this, cx| {
384 let load_result = load_image.await;
385 *tx.borrow_mut() = Some(this.update(cx, |this, _cx| {
386 // Record the fact that the image is no longer loading.
387 this.loading_images_by_path.remove(&project_path);
388 let image = load_result.map_err(Arc::new)?;
389 Ok(image)
390 })?);
391 anyhow::Ok(())
392 })
393 .detach();
394 rx
395 }
396 };
397
398 cx.background_spawn(async move {
399 Self::wait_for_loading_image(loading_watch)
400 .await
401 .map_err(|e| e.cloned())
402 })
403 }
404
405 pub async fn wait_for_loading_image(
406 mut receiver: postage::watch::Receiver<
407 Option<Result<Entity<ImageItem>, Arc<anyhow::Error>>>,
408 >,
409 ) -> Result<Entity<ImageItem>, Arc<anyhow::Error>> {
410 loop {
411 if let Some(result) = receiver.borrow().as_ref() {
412 match result {
413 Ok(image) => return Ok(image.to_owned()),
414 Err(e) => return Err(e.to_owned()),
415 }
416 }
417 receiver.next().await;
418 }
419 }
420
421 pub fn reload_images(
422 &self,
423 images: HashSet<Entity<ImageItem>>,
424 cx: &mut Context<ImageStore>,
425 ) -> Task<Result<()>> {
426 if images.is_empty() {
427 return Task::ready(Ok(()));
428 }
429
430 self.state.reload_images(images, cx)
431 }
432
433 fn add_image(&mut self, image: Entity<ImageItem>, cx: &mut Context<ImageStore>) -> Result<()> {
434 let image_id = image.read(cx).id;
435
436 self.opened_images.insert(image_id, image.downgrade());
437
438 cx.subscribe(&image, Self::on_image_event).detach();
439 cx.emit(ImageStoreEvent::ImageAdded(image));
440 Ok(())
441 }
442
443 fn on_image_event(
444 &mut self,
445 image: Entity<ImageItem>,
446 event: &ImageItemEvent,
447 cx: &mut Context<Self>,
448 ) {
449 if let ImageItemEvent::FileHandleChanged = event
450 && let Some(local) = self.state.as_local()
451 {
452 local.update(cx, |local, cx| {
453 local.image_changed_file(image, cx);
454 })
455 }
456 }
457}
458
459impl ImageStoreImpl for Entity<LocalImageStore> {
460 fn open_image(
461 &self,
462 path: Arc<Path>,
463 worktree: Entity<Worktree>,
464 cx: &mut Context<ImageStore>,
465 ) -> Task<Result<Entity<ImageItem>>> {
466 let this = self.clone();
467
468 let load_file = worktree.update(cx, |worktree, cx| {
469 worktree.load_binary_file(path.as_ref(), cx)
470 });
471 cx.spawn(async move |image_store, cx| {
472 let LoadedBinaryFile { file, content } = load_file.await?;
473 let image = create_gpui_image(content)?;
474
475 let entity = cx.new(|cx| ImageItem {
476 id: cx.entity_id().as_non_zero_u64().into(),
477 file: file.clone(),
478 image,
479 image_metadata: None,
480 reload_task: None,
481 })?;
482
483 let image_id = cx.read_entity(&entity, |model, _| model.id)?;
484
485 this.update(cx, |this, cx| {
486 image_store.update(cx, |image_store, cx| {
487 image_store.add_image(entity.clone(), cx)
488 })??;
489 this.local_image_ids_by_path.insert(
490 ProjectPath {
491 worktree_id: file.worktree_id(cx),
492 path: file.path.clone(),
493 },
494 image_id,
495 );
496
497 if let Some(entry_id) = file.entry_id {
498 this.local_image_ids_by_entry_id.insert(entry_id, image_id);
499 }
500
501 anyhow::Ok(())
502 })??;
503
504 Ok(entity)
505 })
506 }
507
508 fn reload_images(
509 &self,
510 images: HashSet<Entity<ImageItem>>,
511 cx: &mut Context<ImageStore>,
512 ) -> Task<Result<()>> {
513 cx.spawn(async move |_, cx| {
514 for image in images {
515 if let Some(rec) = image.update(cx, |image, cx| image.reload(cx))? {
516 rec.await?
517 }
518 }
519 Ok(())
520 })
521 }
522
523 fn as_local(&self) -> Option<Entity<LocalImageStore>> {
524 Some(self.clone())
525 }
526}
527
528impl LocalImageStore {
529 fn subscribe_to_worktree(&mut self, worktree: &Entity<Worktree>, cx: &mut Context<Self>) {
530 cx.subscribe(worktree, |this, worktree, event, cx| {
531 if worktree.read(cx).is_local()
532 && let worktree::Event::UpdatedEntries(changes) = event
533 {
534 this.local_worktree_entries_changed(&worktree, changes, cx);
535 }
536 })
537 .detach();
538 }
539
540 fn local_worktree_entries_changed(
541 &mut self,
542 worktree_handle: &Entity<Worktree>,
543 changes: &[(Arc<Path>, ProjectEntryId, PathChange)],
544 cx: &mut Context<Self>,
545 ) {
546 let snapshot = worktree_handle.read(cx).snapshot();
547 for (path, entry_id, _) in changes {
548 self.local_worktree_entry_changed(*entry_id, path, worktree_handle, &snapshot, cx);
549 }
550 }
551
552 fn local_worktree_entry_changed(
553 &mut self,
554 entry_id: ProjectEntryId,
555 path: &Arc<Path>,
556 worktree: &Entity<worktree::Worktree>,
557 snapshot: &worktree::Snapshot,
558 cx: &mut Context<Self>,
559 ) -> Option<()> {
560 let project_path = ProjectPath {
561 worktree_id: snapshot.id(),
562 path: path.clone(),
563 };
564 let image_id = match self.local_image_ids_by_entry_id.get(&entry_id) {
565 Some(&image_id) => image_id,
566 None => self.local_image_ids_by_path.get(&project_path).copied()?,
567 };
568
569 let image = self
570 .image_store
571 .update(cx, |image_store, _| {
572 if let Some(image) = image_store.get(image_id) {
573 Some(image)
574 } else {
575 image_store.opened_images.remove(&image_id);
576 None
577 }
578 })
579 .ok()
580 .flatten();
581 let image = if let Some(image) = image {
582 image
583 } else {
584 self.local_image_ids_by_path.remove(&project_path);
585 self.local_image_ids_by_entry_id.remove(&entry_id);
586 return None;
587 };
588
589 image.update(cx, |image, cx| {
590 let old_file = &image.file;
591 if old_file.worktree != *worktree {
592 return;
593 }
594
595 let snapshot_entry = old_file
596 .entry_id
597 .and_then(|entry_id| snapshot.entry_for_id(entry_id))
598 .or_else(|| snapshot.entry_for_path(old_file.path.as_ref()));
599
600 let new_file = if let Some(entry) = snapshot_entry {
601 worktree::File {
602 disk_state: match entry.mtime {
603 Some(mtime) => DiskState::Present { mtime },
604 None => old_file.disk_state,
605 },
606 is_local: true,
607 entry_id: Some(entry.id),
608 path: entry.path.clone(),
609 worktree: worktree.clone(),
610 is_private: entry.is_private,
611 }
612 } else {
613 worktree::File {
614 disk_state: DiskState::Deleted,
615 is_local: true,
616 entry_id: old_file.entry_id,
617 path: old_file.path.clone(),
618 worktree: worktree.clone(),
619 is_private: old_file.is_private,
620 }
621 };
622
623 if new_file == **old_file {
624 return;
625 }
626
627 if new_file.path != old_file.path {
628 self.local_image_ids_by_path.remove(&ProjectPath {
629 path: old_file.path.clone(),
630 worktree_id: old_file.worktree_id(cx),
631 });
632 self.local_image_ids_by_path.insert(
633 ProjectPath {
634 worktree_id: new_file.worktree_id(cx),
635 path: new_file.path.clone(),
636 },
637 image_id,
638 );
639 }
640
641 if new_file.entry_id != old_file.entry_id {
642 if let Some(entry_id) = old_file.entry_id {
643 self.local_image_ids_by_entry_id.remove(&entry_id);
644 }
645 if let Some(entry_id) = new_file.entry_id {
646 self.local_image_ids_by_entry_id.insert(entry_id, image_id);
647 }
648 }
649
650 image.file_updated(Arc::new(new_file), cx);
651 });
652 None
653 }
654
655 fn image_changed_file(&mut self, image: Entity<ImageItem>, cx: &mut App) -> Option<()> {
656 let image = image.read(cx);
657 let file = &image.file;
658
659 let image_id = image.id;
660 if let Some(entry_id) = file.entry_id {
661 match self.local_image_ids_by_entry_id.get(&entry_id) {
662 Some(_) => {
663 return None;
664 }
665 None => {
666 self.local_image_ids_by_entry_id.insert(entry_id, image_id);
667 }
668 }
669 };
670 self.local_image_ids_by_path.insert(
671 ProjectPath {
672 worktree_id: file.worktree_id(cx),
673 path: file.path.clone(),
674 },
675 image_id,
676 );
677
678 Some(())
679 }
680}
681
682fn create_gpui_image(content: Vec<u8>) -> anyhow::Result<Arc<gpui::Image>> {
683 let format = image::guess_format(&content)?;
684
685 Ok(Arc::new(gpui::Image::from_bytes(
686 match format {
687 image::ImageFormat::Png => gpui::ImageFormat::Png,
688 image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg,
689 image::ImageFormat::WebP => gpui::ImageFormat::Webp,
690 image::ImageFormat::Gif => gpui::ImageFormat::Gif,
691 image::ImageFormat::Bmp => gpui::ImageFormat::Bmp,
692 image::ImageFormat::Tiff => gpui::ImageFormat::Tiff,
693 format => anyhow::bail!("Image format {format:?} not supported"),
694 },
695 content,
696 )))
697}
698
699impl ImageStoreImpl for Entity<RemoteImageStore> {
700 fn open_image(
701 &self,
702 _path: Arc<Path>,
703 _worktree: Entity<Worktree>,
704 _cx: &mut Context<ImageStore>,
705 ) -> Task<Result<Entity<ImageItem>>> {
706 Task::ready(Err(anyhow::anyhow!(
707 "Opening images from remote is not supported"
708 )))
709 }
710
711 fn reload_images(
712 &self,
713 _images: HashSet<Entity<ImageItem>>,
714 _cx: &mut Context<ImageStore>,
715 ) -> Task<Result<()>> {
716 Task::ready(Err(anyhow::anyhow!(
717 "Reloading images from remote is not supported"
718 )))
719 }
720
721 fn as_local(&self) -> Option<Entity<LocalImageStore>> {
722 None
723 }
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729 use fs::FakeFs;
730 use gpui::TestAppContext;
731 use serde_json::json;
732 use settings::SettingsStore;
733 use std::path::PathBuf;
734
735 pub fn init_test(cx: &mut TestAppContext) {
736 zlog::init_test();
737
738 cx.update(|cx| {
739 let settings_store = SettingsStore::test(cx);
740 cx.set_global(settings_store);
741 language::init(cx);
742 Project::init_settings(cx);
743 });
744 }
745
746 #[gpui::test]
747 async fn test_image_not_loaded_twice(cx: &mut TestAppContext) {
748 init_test(cx);
749 let fs = FakeFs::new(cx.executor());
750
751 fs.insert_tree("/root", json!({})).await;
752 // Create a png file that consists of a single white pixel
753 fs.insert_file(
754 "/root/image_1.png",
755 vec![
756 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
757 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
758 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78,
759 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
760 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
761 ],
762 )
763 .await;
764
765 let project = Project::test(fs, ["/root".as_ref()], cx).await;
766
767 let worktree_id =
768 cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
769
770 let project_path = ProjectPath {
771 worktree_id,
772 path: PathBuf::from("image_1.png").into(),
773 };
774
775 let (task1, task2) = project.update(cx, |project, cx| {
776 (
777 project.open_image(project_path.clone(), cx),
778 project.open_image(project_path.clone(), cx),
779 )
780 });
781
782 let image1 = task1.await.unwrap();
783 let image2 = task2.await.unwrap();
784
785 assert_eq!(image1, image2);
786 }
787}