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))
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 load_image = self
379 .state
380 .open_image(project_path.path.clone(), worktree, cx);
381
382 cx.spawn(async move |this, cx| {
383 let load_result = load_image.await;
384 *tx.borrow_mut() = Some(this.update(cx, |this, _cx| {
385 // Record the fact that the image is no longer loading.
386 this.loading_images_by_path.remove(&project_path);
387 let image = load_result.map_err(Arc::new)?;
388 Ok(image)
389 })?);
390 anyhow::Ok(())
391 })
392 .detach();
393 rx
394 }
395 };
396
397 cx.background_spawn(async move {
398 Self::wait_for_loading_image(loading_watch)
399 .await
400 .map_err(|e| e.cloned())
401 })
402 }
403
404 pub async fn wait_for_loading_image(
405 mut receiver: postage::watch::Receiver<
406 Option<Result<Entity<ImageItem>, Arc<anyhow::Error>>>,
407 >,
408 ) -> Result<Entity<ImageItem>, Arc<anyhow::Error>> {
409 loop {
410 if let Some(result) = receiver.borrow().as_ref() {
411 match result {
412 Ok(image) => return Ok(image.to_owned()),
413 Err(e) => return Err(e.to_owned()),
414 }
415 }
416 receiver.next().await;
417 }
418 }
419
420 pub fn reload_images(
421 &self,
422 images: HashSet<Entity<ImageItem>>,
423 cx: &mut Context<ImageStore>,
424 ) -> Task<Result<()>> {
425 if images.is_empty() {
426 return Task::ready(Ok(()));
427 }
428
429 self.state.reload_images(images, cx)
430 }
431
432 fn add_image(&mut self, image: Entity<ImageItem>, cx: &mut Context<ImageStore>) -> Result<()> {
433 let image_id = image.read(cx).id;
434
435 self.opened_images.insert(image_id, image.downgrade());
436
437 cx.subscribe(&image, Self::on_image_event).detach();
438 cx.emit(ImageStoreEvent::ImageAdded(image));
439 Ok(())
440 }
441
442 fn on_image_event(
443 &mut self,
444 image: Entity<ImageItem>,
445 event: &ImageItemEvent,
446 cx: &mut Context<Self>,
447 ) {
448 if let ImageItemEvent::FileHandleChanged = event
449 && let Some(local) = self.state.as_local()
450 {
451 local.update(cx, |local, cx| {
452 local.image_changed_file(image, cx);
453 })
454 }
455 }
456}
457
458impl ImageStoreImpl for Entity<LocalImageStore> {
459 fn open_image(
460 &self,
461 path: Arc<Path>,
462 worktree: Entity<Worktree>,
463 cx: &mut Context<ImageStore>,
464 ) -> Task<Result<Entity<ImageItem>>> {
465 let this = self.clone();
466
467 let load_file = worktree.update(cx, |worktree, cx| {
468 worktree.load_binary_file(path.as_ref(), cx)
469 });
470 cx.spawn(async move |image_store, cx| {
471 let LoadedBinaryFile { file, content } = load_file.await?;
472 let image = create_gpui_image(content)?;
473
474 let entity = cx.new(|cx| ImageItem {
475 id: cx.entity_id().as_non_zero_u64().into(),
476 file: file.clone(),
477 image,
478 image_metadata: None,
479 reload_task: None,
480 })?;
481
482 let image_id = cx.read_entity(&entity, |model, _| model.id)?;
483
484 this.update(cx, |this, cx| {
485 image_store.update(cx, |image_store, cx| {
486 image_store.add_image(entity.clone(), cx)
487 })??;
488 this.local_image_ids_by_path.insert(
489 ProjectPath {
490 worktree_id: file.worktree_id(cx),
491 path: file.path.clone(),
492 },
493 image_id,
494 );
495
496 if let Some(entry_id) = file.entry_id {
497 this.local_image_ids_by_entry_id.insert(entry_id, image_id);
498 }
499
500 anyhow::Ok(())
501 })??;
502
503 Ok(entity)
504 })
505 }
506
507 fn reload_images(
508 &self,
509 images: HashSet<Entity<ImageItem>>,
510 cx: &mut Context<ImageStore>,
511 ) -> Task<Result<()>> {
512 cx.spawn(async move |_, cx| {
513 for image in images {
514 if let Some(rec) = image.update(cx, |image, cx| image.reload(cx))? {
515 rec.await?
516 }
517 }
518 Ok(())
519 })
520 }
521
522 fn as_local(&self) -> Option<Entity<LocalImageStore>> {
523 Some(self.clone())
524 }
525}
526
527impl LocalImageStore {
528 fn subscribe_to_worktree(&mut self, worktree: &Entity<Worktree>, cx: &mut Context<Self>) {
529 cx.subscribe(worktree, |this, worktree, event, cx| {
530 if worktree.read(cx).is_local()
531 && let worktree::Event::UpdatedEntries(changes) = event
532 {
533 this.local_worktree_entries_changed(&worktree, changes, cx);
534 }
535 })
536 .detach();
537 }
538
539 fn local_worktree_entries_changed(
540 &mut self,
541 worktree_handle: &Entity<Worktree>,
542 changes: &[(Arc<Path>, ProjectEntryId, PathChange)],
543 cx: &mut Context<Self>,
544 ) {
545 let snapshot = worktree_handle.read(cx).snapshot();
546 for (path, entry_id, _) in changes {
547 self.local_worktree_entry_changed(*entry_id, path, worktree_handle, &snapshot, cx);
548 }
549 }
550
551 fn local_worktree_entry_changed(
552 &mut self,
553 entry_id: ProjectEntryId,
554 path: &Arc<Path>,
555 worktree: &Entity<worktree::Worktree>,
556 snapshot: &worktree::Snapshot,
557 cx: &mut Context<Self>,
558 ) -> Option<()> {
559 let project_path = ProjectPath {
560 worktree_id: snapshot.id(),
561 path: path.clone(),
562 };
563 let image_id = match self.local_image_ids_by_entry_id.get(&entry_id) {
564 Some(&image_id) => image_id,
565 None => self.local_image_ids_by_path.get(&project_path).copied()?,
566 };
567
568 let image = self
569 .image_store
570 .update(cx, |image_store, _| {
571 if let Some(image) = image_store.get(image_id) {
572 Some(image)
573 } else {
574 image_store.opened_images.remove(&image_id);
575 None
576 }
577 })
578 .ok()
579 .flatten();
580 let image = if let Some(image) = image {
581 image
582 } else {
583 self.local_image_ids_by_path.remove(&project_path);
584 self.local_image_ids_by_entry_id.remove(&entry_id);
585 return None;
586 };
587
588 image.update(cx, |image, cx| {
589 let old_file = &image.file;
590 if old_file.worktree != *worktree {
591 return;
592 }
593
594 let snapshot_entry = old_file
595 .entry_id
596 .and_then(|entry_id| snapshot.entry_for_id(entry_id))
597 .or_else(|| snapshot.entry_for_path(old_file.path.as_ref()));
598
599 let new_file = if let Some(entry) = snapshot_entry {
600 worktree::File {
601 disk_state: match entry.mtime {
602 Some(mtime) => DiskState::Present { mtime },
603 None => old_file.disk_state,
604 },
605 is_local: true,
606 entry_id: Some(entry.id),
607 path: entry.path.clone(),
608 worktree: worktree.clone(),
609 is_private: entry.is_private,
610 }
611 } else {
612 worktree::File {
613 disk_state: DiskState::Deleted,
614 is_local: true,
615 entry_id: old_file.entry_id,
616 path: old_file.path.clone(),
617 worktree: worktree.clone(),
618 is_private: old_file.is_private,
619 }
620 };
621
622 if new_file == **old_file {
623 return;
624 }
625
626 if new_file.path != old_file.path {
627 self.local_image_ids_by_path.remove(&ProjectPath {
628 path: old_file.path.clone(),
629 worktree_id: old_file.worktree_id(cx),
630 });
631 self.local_image_ids_by_path.insert(
632 ProjectPath {
633 worktree_id: new_file.worktree_id(cx),
634 path: new_file.path.clone(),
635 },
636 image_id,
637 );
638 }
639
640 if new_file.entry_id != old_file.entry_id {
641 if let Some(entry_id) = old_file.entry_id {
642 self.local_image_ids_by_entry_id.remove(&entry_id);
643 }
644 if let Some(entry_id) = new_file.entry_id {
645 self.local_image_ids_by_entry_id.insert(entry_id, image_id);
646 }
647 }
648
649 image.file_updated(Arc::new(new_file), cx);
650 });
651 None
652 }
653
654 fn image_changed_file(&mut self, image: Entity<ImageItem>, cx: &mut App) -> Option<()> {
655 let image = image.read(cx);
656 let file = &image.file;
657
658 let image_id = image.id;
659 if let Some(entry_id) = file.entry_id {
660 match self.local_image_ids_by_entry_id.get(&entry_id) {
661 Some(_) => {
662 return None;
663 }
664 None => {
665 self.local_image_ids_by_entry_id.insert(entry_id, image_id);
666 }
667 }
668 };
669 self.local_image_ids_by_path.insert(
670 ProjectPath {
671 worktree_id: file.worktree_id(cx),
672 path: file.path.clone(),
673 },
674 image_id,
675 );
676
677 Some(())
678 }
679}
680
681fn create_gpui_image(content: Vec<u8>) -> anyhow::Result<Arc<gpui::Image>> {
682 let format = image::guess_format(&content)?;
683
684 Ok(Arc::new(gpui::Image::from_bytes(
685 match format {
686 image::ImageFormat::Png => gpui::ImageFormat::Png,
687 image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg,
688 image::ImageFormat::WebP => gpui::ImageFormat::Webp,
689 image::ImageFormat::Gif => gpui::ImageFormat::Gif,
690 image::ImageFormat::Bmp => gpui::ImageFormat::Bmp,
691 image::ImageFormat::Tiff => gpui::ImageFormat::Tiff,
692 format => anyhow::bail!("Image format {format:?} not supported"),
693 },
694 content,
695 )))
696}
697
698impl ImageStoreImpl for Entity<RemoteImageStore> {
699 fn open_image(
700 &self,
701 _path: Arc<Path>,
702 _worktree: Entity<Worktree>,
703 _cx: &mut Context<ImageStore>,
704 ) -> Task<Result<Entity<ImageItem>>> {
705 Task::ready(Err(anyhow::anyhow!(
706 "Opening images from remote is not supported"
707 )))
708 }
709
710 fn reload_images(
711 &self,
712 _images: HashSet<Entity<ImageItem>>,
713 _cx: &mut Context<ImageStore>,
714 ) -> Task<Result<()>> {
715 Task::ready(Err(anyhow::anyhow!(
716 "Reloading images from remote is not supported"
717 )))
718 }
719
720 fn as_local(&self) -> Option<Entity<LocalImageStore>> {
721 None
722 }
723}
724
725#[cfg(test)]
726mod tests {
727 use super::*;
728 use fs::FakeFs;
729 use gpui::TestAppContext;
730 use serde_json::json;
731 use settings::SettingsStore;
732 use std::path::PathBuf;
733
734 pub fn init_test(cx: &mut TestAppContext) {
735 zlog::init_test();
736
737 cx.update(|cx| {
738 let settings_store = SettingsStore::test(cx);
739 cx.set_global(settings_store);
740 language::init(cx);
741 Project::init_settings(cx);
742 });
743 }
744
745 #[gpui::test]
746 async fn test_image_not_loaded_twice(cx: &mut TestAppContext) {
747 init_test(cx);
748 let fs = FakeFs::new(cx.executor());
749
750 fs.insert_tree("/root", json!({})).await;
751 // Create a png file that consists of a single white pixel
752 fs.insert_file(
753 "/root/image_1.png",
754 vec![
755 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
756 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
757 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78,
758 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
759 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
760 ],
761 )
762 .await;
763
764 let project = Project::test(fs, ["/root".as_ref()], cx).await;
765
766 let worktree_id =
767 cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
768
769 let project_path = ProjectPath {
770 worktree_id,
771 path: PathBuf::from("image_1.png").into(),
772 };
773
774 let (task1, task2) = project.update(cx, |project, cx| {
775 (
776 project.open_image(project_path.clone(), cx),
777 project.open_image(project_path.clone(), cx),
778 )
779 });
780
781 let image1 = task1.await.unwrap();
782 let image2 = task2.await.unwrap();
783
784 assert_eq!(image1, image2);
785 }
786}